12.8. Потокова передача кадрів

Найпоширеніше практичне використання власного каналу — це потокова передача кадрів зображення з камери до хост-програми зі швидкістю захоплення камери. Механіка тут тонша, ніж здається: JPEG може займати 25 КБ і більше, тому хост зчитує його частинами, а цикл захоплення камери не повинен перезаписувати буфер під час читання. Правильний шаблон — показаний тут і використовуваний інструментами з openmv-projects/tools/фіксує буфер до завершення передачі останнього байта хостом.

12.8.1. Сторона камери

Канал кадрів, що захоплює дані в єдиний кадровий буфер, фіксує його при першому зверненні хоста і робить наступний знімок лише після того, як хост повністю отримав зображення:

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

Тут виконують реальну роботу чотири компоненти:

  • frame_available — це засувка. Цикл захоплення робить новий знімок лише тоді, коли вона має значення False — тобто коли хост отримав останній байт попереднього кадру. Читання хостом знову встановлює її в False зсередини readp після передачі фінального зміщення. Без цього захисту наступний csi0.snapshot() перезаписав би буфер під час читання, і хост отримав би кадр, зшитий із двох захоплень.

  • readp замість read — це те, що реалізує бекенд. Бібліотека протоколу розглядає повернений буфер як авторитетний і зчитує його байти безпосередньо у вихідний пакет — без копіювання. Для навантажень розміром кадру readp помітно швидший за read, який вимагає проміжного копіювання.

  • size повертає кешовану довжину JPEG без повторного обчислення; цикл захоплення оновлює її щоразу, коли оновлює буфер. Хост викликає size між poll і readp, щоб знати, скільки байтів потрібно отримати.

  • send_event() повідомляє хост у момент, коли надходить новий кадр, щоб він міг почати читання без опитування. Ідентифікатор події 0x01 визначається застосунком («кадр готовий» у цьому випадку); для кожного типу сповіщення використовуйте інше мале ціле число.

12.8.2. Фрагментація

QVGA RGB565 при якості JPEG 85 стискається приблизно до 10–25 КБ залежно від сцени — значно більше за узгоджений максимальний розмір корисного навантаження на будь-якій камері (дивіться таблицю для кожної плати у protocol.init()). Один запит читання JPEG не вміститься в один пакет, але це нормально, оскільки бібліотека протоколу автоматично виконує фрагментацію.

Коли хост запитує channel_read('frame', 12000):

  1. readp камери викликається один раз із offset=0 і повним запитом на 12000 байтів. Він повертає один memoryview, що охоплює весь діапазон.

  2. Бібліотека протоколу розбиває цей memoryview на фрагменти максимального розміру корисного навантаження в дроті, по одному пакету відповіді CHANNEL_READ на фрагмент, кожен зі своїм заголовком і CRC. Байти передаються безпосередньо з буфера бекенда — без копіювання.

  3. Хост отримує фрагменти по порядку, рівень надійності повторно передає будь-який фрагмент, що не пройшов перевірку CRC, а SDK хоста збирає фрагменти в результат з 12000 байтів, що повертається викликачу.

Примітка

Це ключова практична різниця між readp і read. readp викликається один раз на запит хоста; рівень протоколу фрагментує і передає дані з єдиного поверненого буфера. read викликається один раз на фрагмент, і бібліотека копіює кожен повернений фрагмент у власний буфер пакета. Для навантажень розміром кадру readp економить як накладні витрати на виклик Python на рівні фрагмента, так і копіювання.

Порада

Хочете побачити різницю самостійно? Перейменуйте метод readp бекенда на read — більше нічого не змінюйте; бібліотека підхопить можливість read замість цього — і порівняйте лічильник кількості кадрів хоста до та після. Менша цифра — це вартість копіювання та виклику Python на рівні фрагмента, яку ви уникаєте, використовуючи readp.

Засувка в FrameChannel.readp звільняє буфер, коли offset + size == img_size — у момент, коли хост отримав останній байт. До цього буфер повинен залишатися дійсним, тому цикл захоплення робить наступний знімок лише після того, як frame_available повертається до False.

12.8.3. Сторона хоста

Хост отримує кадри у щільному циклі:

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

Виклик channel_size() одночасно слугує перевіркою «чи є що читати» — нуль означає, що камера ще не захопила нічого — тому цикл пропускає спроби читання при порожньому буфері. Для GUI-застосунків, які вже опитують за таймером, це природний шаблон.

Image.open бібліотеки Pillow декодує JPEG; камера вже стиснула зображення у JPEG, тому хосту не потрібно заново виконувати дороге побітове пакування RGB565. Хост-скрипт міг би так само легко зберегти байти на диск, передати їх в OpenCV або відправити через веб-інтерфейс.

12.8.4. Думки про пропускну здатність

Досяжну частоту кадрів обмежують три чинники:

  • Швидкість захоплення камери. Протокол не може доставляти кадри швидше, ніж датчик їх виробляє; яке б обмеження не накладав обраний піксельний формат і розмір кадру на захоплення — це і є стеля.

  • Узгоджений максимальний розмір корисного навантаження. Більші навантаження означають менше фрагментів на кадр і менші накладні витрати на фреймінг, тому камери з більшими буферами протоколу передають байти швидше, ніж менші.

  • Накладні витрати CRC та ACK. Кожен пакет коштує 14 байтів фреймінгу плюс один оберт ACK. Для довгих фрагментів накладні витрати на навантаження невеликі; для маленьких навантажень вони домінують.

Для більшості роботи з GUI «камера–ноутбук» обмежуючим чинником є час захоплення та стиснення JPEG камери, а не стек протоколу. Там, де протокол стає вузьким місцем — наприклад, потокова передача нестиснутих сирих кадрів з високою частотою — важелями є відключення ACK (protocol.init(ack=False)), збільшення буфера протоколу, якщо камера це підтримує, або захоплення у GRAYSCALE, щоб кожен стиснутий JPEG містив один канал замість трьох і закодований кадр виходив помітно меншим у дроті.

Канал кадрів — це канонічний потік даних «камера–хост». Той самий інтерфейс бекенда з доданим методом write дозволяє хосту відправляти дані у зворотному напрямку — що і потрібно інтерактивному інструменту для роботи з камерою, щойно оператор захоче щось змінити, а не просто спостерігати.