У SpeedyBot четыре камеры, но заводская система только записывала видео в суточный локальный архив. Мы добавили просмотр в Fleet: сначала JPEG по два кадра в секунду, затем поток 720p с показом около 25 FPS через 3G.

Я Лев Ларин, инженер Unibot. Мы развиваем UNIBOT Fleet и подключаем к ней робота-доставщика Alfa Robotics SpeedyBot, заменяя облачное управление. Это третья часть серии.

Начало серии · ← Часть 2: робот в тумане · Часть 4: CRISPR для ARM →

SpeedyBot: 25 FPS по 3G
SpeedyBot: 25 FPS по 3G

Камеры и бортовой компьютер

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

Четыре камеры по периметру SpeedyBot, поворот передней и задней, внутренняя Ethernet-сеть и плата RK3588
Четыре камеры по периметру SpeedyBot, поворот передней и задней, внутренняя Ethernet-сеть и плата RK3588

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

Каталожное фото сетевого модуля H46 с объективом, Ethernet и питанием
Каталожное фото сетевого модуля H46 с объективом, Ethernet и питанием

Модуль семейства H46, фото Shenzhen Weinan. Параметры нашего экземпляра считаны с устройств:

Что выяснили о камерах нашего SpeedyBot

Значение

Идентификация через ONVIF

Vatilon H46

Основной поток

/stream1; заявлены 1280×720, 1920×1080, 2304×1296 и 2560×1440

Дополнительный поток

/stream2; среди режимов — 640×480

Кодирование

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 — период ключевых кадров.

Настоящий SpeedyBot с открытым грузовым отсеком
Настоящий SpeedyBot с открытым грузовым отсеком

Почему 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, -q:v 8

33,3 КБ

0,533

10 FPS, -q:v 8

33,3 КБ

2,665

10 FPS, -q:v 12

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 отображается вертикально, сжатые пакеты не меняются.

Старый тракт декодировал видео в JPEG; новый передаёт готовый H.264 в MP4-фрагментах
Старый тракт декодировал видео в JPEG; новый передаёт готовый H.264 в MP4-фрагментах

Настройка 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.

Реальная Fleet с открытой задней камерой в контрольном прогоне 3G
Реальная Fleet с открытой задней камерой в контрольном прогоне 3G

Многосекундных остановок в этих просмотрах не было. Открытие камеры пока занимает больше секунды.

Все 10 историй SpeedyBot

  1. Робот доставщик дороже автомобиля, мокрая трава и открытый JDWP

  2. Робот в тумане: лошадь оставить, туман удалить

  3. 25 FPS с видеокамеры по 3G. Модем, извини — вы здесь.

  4. CRISPR для ARM: чиним ДНК робота

  5. Гражданин куст, уступите дорогу

  6. За пивом за угол — через Магадан

  7. К бабушке с пирожками — за край карты

  8. Парк, я тебя отрендерил

  9. Джойстик отпустили. Робот не в курсе

  10. Мечтают ли роботы о свободных ядрах?

Комментарии (4)


  1. akakoychenko
    23.10.2026 10:14

    А нет специальных алгоритмов для сжатия под подобные задачи? Все же, алгоритмы делались под смотрение разного видео, а тут суперчастный случай, где важно конкретные детали видео передать, существенные для вождения


    1. Tigramaan Автор
      23.10.2026 10:14

      Детали нужны на самом роботе, локально. Робот обрабатывает видео для навигации сам. В этом случае задача была именно передать видеопоток человеку - оператору. Скорее с точки зрения сервиса.. Мы релизовали возможность "сказать что-то" через робота.. Типа эй гражданин с табуреткой - помоги роботу выехать из канавы.. Но для эьтого надо позвать конкретного селовека и увидеть его.. Ну или хулиганы обижают робота.. короче этот видеопоток скорее для навигации. Есть еще возможность управлять роботом с джойстика. тогда надо как в гонках - сразу видеть куда он едет .. Короче в нашем случае мелкие детали не принципиальны. Принципиальна скорость потока.


      1. akakoychenko
        23.10.2026 10:14

        Так тут сразу 2 задачи. И быстрый поток, и суперкачечественное слайдшоу. Ибо, фото хулиганов надо в максимально возможном качестве записать. Они могут и безвозвратно внутренний носитель повредить, например, толкнув робота с моста


        1. Tigramaan Автор
          23.10.2026 10:14

          Конкретно в этой задаче мы не пишем стрим на сервер и не храним на сервере. Только буфер. Иначе нам пришлось бы отвечать за хранение персональных данных а нам это ни к чему.. Фото и видео будут считаться персональными данными..