У SpeedyBot четыре камеры, но заводская система только записывала видео в суточный локальный архив. Мы добавили просмотр в Fleet: сначала JPEG по два кадра в секунду, затем поток 720p с показом около 25 FPS через 3G.
Я Лев Ларин, инженер Unibot. Мы развиваем UNIBOT Fleet и подключаем к ней робота-доставщика Alfa Robotics SpeedyBot, заменяя облачное управление. Это третья часть серии.
Начало серии · ← Часть 2: робот в тумане · Часть 4: CRISPR для ARM →

Камеры и бортовой компьютер
Обзорные IP-камеры стоят спереди, сзади и по бокам. Передняя и задняя повёрнуты на 90°. Они подключены к внутренней Ethernet-сети и передают уже сжатый поток. Камера глубины и лидар используются отдельно для навигации.

Через ONVIF камеры определяются как Vatilon H46: компактные модули с собственным видеокодировщиком.

Модуль семейства H46, фото Shenzhen Weinan. Параметры нашего экземпляра считаны с устройств:
Что выяснили о камерах нашего SpeedyBot |
Значение |
|---|---|
Идентификация через ONVIF |
Vatilon H46 |
Основной поток |
|
Дополнительный поток |
|
Кодирование |
H.264 и H.265; готовый сжатый поток по RTSP |
Управление |
ONVIF и родной HTTP API камеры |
Наш дневной профиль |
H.264, 1280×720, фактические 25 FPS, задано 128 кбит/с |
Linux определяет бортовую плату как Rockchip RK3588 ZYSJ 2288K v01 Board: четыре Cortex-A76 и четыре Cortex-A55. Это семейство промышленных плат 2288K с Ethernet и CAN. Здесь работают Linux, ROS, локализация, навигация и наш Edge.
Регистратор продолжает писать дополнительный поток в суточный архив. Через мобильную сеть передаём основной поток одной выбранной камеры.
Проверка RTSP
Hikvision-подобный адрес /Streaming/Channels/101 вернул 640×480: камера направила запрос в дополнительный поток. Настоящие /stream1 и /stream2 нашли через ONVIF GetProfiles и GetStreamUri.
ONVIF сообщал H.264, но ffprobe обнаружил HEVC, 1920×1080 и фактические 10 FPS при r_frame_rate=25. Проверяли пакеты; RTSP_URL в примере — адрес камеры с реквизитами доступа:
ffprobe -v error -rtsp_transport tcp \ -read_intervals '%+12' -select_streams v:0 \ -show_streams -show_packets \ -show_entries 'stream=codec_name,width,height,r_frame_rate:packet=pts_time,size,flags' \ -of json "$RTSP_URL" > probe.json
В нашем H46 один видеопакет соответствует кадру. Частота: (N−1)/(последний PTS−первый PTS). Средний битрейт: сумма packet.size × 8, делённая на длительность. Интервал между PTS пакетов с флагом K даёт GOP — период ключевых кадров.

Почему JPEG не поместился в LTE
Первый обработчик декодировал RTSP, поворачивал изображение, уменьшал его до 640×480 и заново кодировал JPEG. Фрагмент FFmpeg:
-vf fps=2,transpose=1,scale=640:480 -q:v 8 -f image2pipe
-q:v 8 задаёт квантователь, а не процент качества. На одной неподвижной сцене получили:
Режим JPEG |
Средний размер кадра |
Только изображения, Мбит/с |
|---|---|---|
2 FPS, |
33,3 КБ |
0,533 |
10 FPS, |
33,3 КБ |
2,665 |
10 FPS, |
24,9 КБ |
1,989 |
Три HTTPS-загрузки по 2 МиБ через LTE дали 1,785; 2,056 и 1,928 Мбит/с. Десять JPEG требовали 2,665 Мбит/с ещё без накладных расходов транспорта.
JPEG сжимает кадры независимо. H.264 использует опорные кадры и предсказание изменений; этот кодировщик уже работал в камере. Вместо декодирования и повторного сжатия стали пересылать его пакеты:
H46: H.264 → RTSP/TCP → Python/PyAV на роботе → fMP4 → отдельный WebSocket → сервер Fleet → HTTP video/mp4 → MediaSource → обычный <video> в браузере
На роботе — PyAV 16.0.1 над FFmpeg и websockets, на сервере — FastAPI, в интерфейсе — React/TypeScript. PyAV меняет упаковку H.264. Поворот передней и задней камер выполняет CSS rotate(90deg) с перестановкой размеров блока. Поток 1280×720 отображается вертикально, сжатые пакеты не меняются.

Настройка H46
Выбрали H.264, 1280×720 и ключевой кадр примерно раз в секунду. Прежний GOP около четырёх секунд задерживал подключение.
ONVIF SetVideoEncoderConfiguration возвращал успех, но GOP не менялся. Для настройки используем /cgi-bin/web.cgi: штатный вход выдаёт Session-Id, mod=video&cmd=get_main читает основной профиль, set_main записывает его:
configuration = { **original, # полный ответ get_main, остальные поля сохраняем "enc_type": 0, # H.264 именно в API H46 "width": 1280, "height": 720, "framerate": 30, "bitrate": 128, # кбит/с "gop": 1, # в этом API секунды, а не число кадров } payload = { "mod": "video", "cmd": "set_main", "param": {"channel": 0}, "param2": configuration, }
Запрос передаётся с Session-Id и Content-Type: application/json. Дополнительный поток, экспозицию и архив не перенастраиваем. Читаем профиль обратно и проверяем RTSP: запрос 30 FPS камера приводила к фактическим 25. Ночью ранее получали около 10 FPS; дневная настройка не устраняет длинную выдержку.
Битрейт снижали с 800 до 256 и 128 кбит/с. Первый ключевой фрагмент передней камеры уменьшился с 24 622 до 5 822 байт; по четырём направлениям диапазоны составили 24,6–32,8 и 5,8–9,3 КБ. Так сократился стартовый всплеск. Размер первого фрагмента не равен среднему битрейту: последующие ключевые кадры могут быть крупнее.
При 128 кбит/с мелкие детали 720p теряются. Для обзора оператору этого хватило; для чтения табличек нужен другой профиль.
Упаковка в PyAV
Открытие RTSP:
camera = av.open(rtsp_url, options={ "rtsp_transport": "tcp", "analyzeduration": "1", "probesize": "32768", "fpsprobesize": "0", }, timeout=(5.0, 2.0)) # открытие / ожидание чтения, секунды
Кодек и размеры берём из SDP, предварительный анализ ограничиваем для быстрого старта. Слишком маленький probesize мешал: при 32 байтах ключевой фрагмент не появлялся.
Вызываем camera.demux(stream) и output.mux(packet) без decode(), to_ndarray() и encode(). writer отдаёт собранные MP4-фрагменты в очередь:
with av.open(writer, "w", format="mp4", options={ "movflags": "empty_moov+default_base_moof+frag_keyframe", "frag_duration": "100000", "flush_packets": "1", }) as output: first_dts = None for packet in camera.demux(stream): if (first_dts is None and packet.is_keyframe and packet.pts is None and packet.dts is None): packet.pts = packet.dts = 0 # особенность старта нашего H46 if packet.pts is None or packet.dts is None or not packet.size: continue if first_dts is None: if not packet.is_keyframe: continue first_dts = packet.dts video = output.add_stream_from_template(stream) packet.pts -= first_dts packet.dts -= first_dts packet.stream = video output.mux(packet)
frag_duration=100000 задаёт фрагменты около 100 мс, ключевой кадр начинает новый фрагмент. empty_moov позволяет сразу передать метаданные, default_base_moof убирает зависимость от абсолютных смещений файла. Подробности — в документации MP4 muxer FFmpeg.
Учли две особенности H46. При RTP, начинающемся с нуля, libav терял PTS/DTS первого ключевого пакета: для этого случая восстанавливаем ноль. Выходной поток создаём после получения ключевого кадра: после перенастройки SPS/PPS могли появиться только в нём.
Очередь отправки
В TCP send queue на роботе накапливалось до 1,09 МБ. При 1 Мбит/с один мегабайт передаётся около восьми секунд, поэтому живое видео превращалось в просмотр прошлого.
На отдельном видеосокете задали:
channel.socket.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, 16 * 1024) MAX_PENDING_FRAGMENTS = 10 # вместо 30, фрагменты около 100 мс
16 КиБ — запрошенный буфер. В установленном повторе наблюдали 42–46 КиБ очереди вместо мегабайта. При переполнении прикладной очереди сбрасываем накопленное, повторяем init-сегмент и ждём свежий ключевой фрагмент. Продолжать с произвольного P-кадра нельзя: он может ссылаться на отброшенные данные.
Плеер MediaSource
Браузер использует MediaSource, addSourceBuffer('video/mp4; codecs="' + codec + '"') и appendBuffer(). Между операциями ждём updateend. Строку кодека avc1.… извлекаем из avcC начального MP4-сегмента; декодирует обычный <video>.
Очистка истории старше двух секунд через SourceBuffer.remove() вызывала зависания. По алгоритму MSE удаление учитывает зависимости кадров и может дойти до следующей точки произвольного доступа. При GOP четыре секунды оно захватывало ещё нужные плееру данные.
Оставляем 30 секунд позади позиции воспроизведения и чистим порциями по пять секунд. Отставание от эфира исправляем отдельно:
if (!video.seeking && video.buffered.length) { const i = video.buffered.length - 1; const start = video.buffered.start(i), end = video.buffered.end(i); if (end - video.currentTime > 1) video.currentTime = Math.max(start, end - 0.4); }
Проверка video.seeking предотвращает новую перемотку до завершения предыдущей. Хранимые 30 секунд истории увеличивают расход памяти, но не задержку просмотра.
Модем и мобильный маршрут
Штатный Quectel EC200A подключён к RK3588 по USB. Это LTE Cat 4 с UMTS/3G и 2G; паспортные пределы LTE — 150 Мбит/с на приём и 50 Мбит/с на передачу. Фактическую отдачу измеряли отдельно.
ModemManager видит AT-порты ttyUSB1/ttyUSB2 и сетевой usb0. Наше рабочее GSM-соединение NetworkManager поднял на ttyUSB2 через PPP, с IP-интерфейсом ppp0:
IP-камера → Ethernet → RK3588/PyAV → TCP/TLS → ppp0 → USB → EC200A → базовая станция → Fleet → браузер
Команды для проверки устройства и маршрута:
mmcli -L # найти номер модема mmcli -m 0 # модель, порты, access tech, состояние nmcli -f DEVICE,TYPE,STATE device ip -s link show ppp0 # счётчики именно мобильного интерфейса ip route get <IP_ВАШЕГО_RELAY> # каким путём реально уйдёт видео
Ранние HTTPS-пробы привязывали к ppp0 через SO_BINDTODEVICE; в финальном прогоне проверяли маршрут видеосоединений, чтобы исключить передачу через Wi-Fi.
Битрейт в LTE и 3G
Профиль камеры в последних опытах одинаковый:
Радиорежим |
Видео выбранной камеры |
Что измерили дополнительно |
|---|---|---|
4G/LTE |
H.264, 1280×720, дневные 25 FPS, задано 128 кбит/с |
Ранние HTTPS-пробы отдачи: 1,785–2,056 Мбит/с |
3G/UMTS |
H.264, 1280×720, дневные 25 FPS, задано 128 кбит/с |
Финальный входящий видеоканал на relay: около 142 кбит/с |
В строке LTE измерена доступная отдача HTTPS-серверу, в строке 3G — расход работающего видео. 128 кбит/с выставили вручную до перехода в UMTS; автоматической адаптации нет. Максимальную скорость 3G не измеряли.
На медиарелее приняли 177 194 байта за 10,0099 с: 177194×8/10,0099 ≈ 141615 бит/с, около 142 кбит/с с упаковкой видеоканала. Полный расход интерфейса с TCP/TLS и другими соединениями отличается.
За десять минут сохранили 597 наблюдений ModemManager: все показывали UMTS. Видео шло через ppp0, без Wi-Fi. Ранее модем регистрировался и в LTE.
Результат в браузере
Робот стоял, четыре камеры открывали по очереди. Результат дневного 3G-прогона:
Камера |
Показываемых FPS, примерно |
От нажатия до первого кадра |
|---|---|---|
Передняя |
24,14 |
1,081 с |
Левая |
24,74 |
1,339 с |
Правая |
24,38 |
1,008 с |
Задняя |
24,30 |
0,925 с |
Показ считали через requestVideoFrameCallback, RTSP — отдельно по PTS.

Многосекундных остановок в этих просмотрах не было. Открытие камеры пока занимает больше секунды.
Все 10 историй SpeedyBot
Робот доставщик дороже автомобиля, мокрая трава и открытый JDWP
25 FPS с видеокамеры по 3G. Модем, извини — вы здесь.
akakoychenko
А нет специальных алгоритмов для сжатия под подобные задачи? Все же, алгоритмы делались под смотрение разного видео, а тут суперчастный случай, где важно конкретные детали видео передать, существенные для вождения
Tigramaan Автор
Детали нужны на самом роботе, локально. Робот обрабатывает видео для навигации сам. В этом случае задача была именно передать видеопоток человеку - оператору. Скорее с точки зрения сервиса.. Мы релизовали возможность "сказать что-то" через робота.. Типа эй гражданин с табуреткой - помоги роботу выехать из канавы.. Но для эьтого надо позвать конкретного селовека и увидеть его.. Ну или хулиганы обижают робота.. короче этот видеопоток скорее для навигации. Есть еще возможность управлять роботом с джойстика. тогда надо как в гонках - сразу видеть куда он едет .. Короче в нашем случае мелкие детали не принципиальны. Принципиальна скорость потока.
akakoychenko
Так тут сразу 2 задачи. И быстрый поток, и суперкачечественное слайдшоу. Ибо, фото хулиганов надо в максимально возможном качестве записать. Они могут и безвозвратно внутренний носитель повредить, например, толкнув робота с моста
Tigramaan Автор
Конкретно в этой задаче мы не пишем стрим на сервер и не храним на сервере. Только буфер. Иначе нам пришлось бы отвечать за хранение персональных данных а нам это ни к чему.. Фото и видео будут считаться персональными данными..