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):
readpкамеры вызывается один раз сoffset=0и полным запросом на 12000 байтов. Он возвращает один memoryview, охватывающий весь диапазон.Библиотека протокола разбивает этот memoryview на фрагменты размером с максимальную полезную нагрузку на канале, один ответный пакет
CHANNEL_READна каждый фрагмент, каждый со своим заголовком и CRC. Байты передаются напрямую из буфера бэкенда – без копирования.Хост получает фрагменты по порядку, уровень надёжности повторно передаёт любой блок, не прошедший проверку 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() заодно служит проверкой «готово ли что-нибудь» – ноль означает, что камера ещё не сделала захват – поэтому цикл пропускает попытки чтения для пустого буфера. Для приложений с графическим интерфейсом, которые и так опрашивают по таймеру, это естественный подход.
Image.open из Pillow декодирует JPEG; камера уже сжала его в JPEG, поэтому хосту не нужно заново выполнять дорогостоящую упаковку битов для RGB565. Хост-скрипт мог бы с тем же успехом сохранить байты на диск, передать их в OpenCV или отправить в веб-представление.
12.8.4. Соображения о пропускной способности¶
Достижимую частоту кадров ограничивают три фактора:
Частота захвата камеры. Протокол не может доставлять кадры быстрее, чем датчик их производит; каким бы ни был предел захвата, накладываемый выбранным форматом пикселей и размером кадра, он и есть потолок.
Согласованная максимальная полезная нагрузка. Бо́льшие полезные нагрузки означают меньше фрагментов на кадр и меньше накладных расходов на формирование пакетов, поэтому камеры с бо́льшими буферами протокола передают байты быстрее, чем камеры с меньшими.
Накладные расходы на CRC и ACK. Каждый пакет стоит 14 байтов формирования плюс один цикл подтверждения ACK. Для длинных фрагментов накладные расходы на полезную нагрузку малы; для крошечных полезных нагрузок они доминируют.
Для большинства задач передачи данных с камеры на ноутбук в графическом интерфейсе ограничивающим фактором является время захвата и JPEG-сжатия камеры, а не стек протокола. Там, где протокол всё же становится узким местом – например, при потоковой передаче несжатых сырых кадров с высокой частотой – рычагами являются отключение ACK (protocol.init(ack=False)), увеличение буфера протокола, если камера это поддерживает, или захват в GRAYSCALE, чтобы каждый сжатый JPEG нёс один канал вместо трёх и закодированный кадр оказывался заметно меньше на линии.
Канал кадров – это канонический поток данных с камеры на хост. Тот же интерфейс бэкенда с добавленным методом write позволяет хосту передавать данные и в обратную сторону – именно это и нужно интерактивному инструменту для камеры, как только оператор хочет изменить что-то, а не просто наблюдать.