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

У SpeedyBot четыре камеры по периметру, но в заводской системе оператору не дали живого просмотра. Видео циклически писалось в локальное хранилище с глубиной в сутки. Получился робот с четырьмя глазами и хорошей памятью: посмотреть, почему он остановился, проще было постфактум.
Мы добавили просмотр в свою Fleet-панель. Сначала самым коротким путём: взять кадр, повернуть, сжать в JPEG и отправить. Два раза в секунду. Вот фрагмент нашей первой команды FFmpeg:
-vf fps=2,transpose=1,scale=640:480 -q:v 8 -f image2pipe
Дальше захотелось нормального видео. Но десять таких JPEG просили 2,67 Мбит/с, а измеренная отдача нашего 4G-модема была меньше. Пришлось разбираться, что за камеры спрятаны в роботе и как донести их поток до оператора.
Я Лев Ларин, инженер Unibot. Мы развиваем UNIBOT Fleet и подключаем к ней Alfa Robotics SpeedyBot — робота-доставщика с грузовым отсеком, камерами и лидарами. Переделываем управление и работу с данными под свою платформу. Это третья история серии из десяти; в ней получаем 720p и около 25 FPS через 3G, сохранив штатные камеры и модем.
Начало серии · ← Часть 2: робот в тумане · Часть 4: CRISPR для ARM →

Что у робота под кожухом
Обзорные камеры стоят спереди, сзади, слева и справа. Передняя и задняя установлены с поворотом на 90°: сырой кадр лежит на боку. Для нормального просмотра его нужно развернуть. Это именно четыре обзорные IP-камеры; лидар и камера глубины решают свои задачи навигации.

По ONVIF наши камеры представляются как Vatilon H46. Это класс компактных IP-модулей: сенсор, объектив и собственный процессор с видеокодировщиком. С бортовым компьютером они общаются через внутреннюю Ethernet-сеть. По сети приходит уже сжатое видео; подключать четыре сырых матрицы к RK3588 нам не требуется.

IP-модуль семейства 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. В RK3588 восемь CPU-ядер: четыре Cortex-A76 и четыре Cortex-A55. У семейства плат 2288K есть Ethernet, CAN и периферийные интерфейсы — это бортовой компьютер, на котором работают Linux, ROS и наша программа связи с Fleet. Он занят ещё и локализацией с навигацией. Повторное изготовление JPEG добавляло ему работу, которую уже выполнил процессор камеры.
Штатный видеорегистратор продолжает брать дополнительный поток и вести суточный архив. Для нашего живого просмотра используем основной поток одной выбранной камеры. Четыре камеры на корпусе не означают четыре одновременные трансляции через мобильную сеть.
Камера согласилась со всем, даже с неправильным адресом
Сначала мы по привычке запросили Hikvision-подобный /Streaming/Channels/101. Получили 640×480 и решили, что нашли основной поток. Камера спокойно обслужила ошибочную догадку: этот адрес тоже приводил к дополнительному потоку. Через ONVIF GetProfiles и GetStreamUri нашли настоящие URI: /stream1 и /stream2.
Дальше стало веселее. ONVIF описывал основной поток как H.264. ffprobe обнаружил HEVC, 1920×1080 и фактические 10 FPS. В r_frame_rate при этом красовалось 25.
Проверять такую камеру полезнее по пакетам. Вот команда, которой можно посмотреть собственный RTSP-источник; 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
Для частоты берём число видеопакетов с временными метками: (N - 1) / (последний PTS - первый PTS). Для среднего битрейта складываем packet.size, умножаем на восемь и делим на длительность. Флаги с K показывают ключевые кадры; разность их PTS даёт интервал GOP. В нашем H46 один такой видеопакет соответствует одному кадру. Уже эти три проверки разоблачили меню камеры.

Наш SpeedyBot. Камеры работают внутри его локальной сети; в мобильный канал отправляем видео выбранного направления.
Почему десять JPEG не помещались в 4G
Наш первый обработчик декодировал RTSP, поворачивал кадр у передней и задней камер, уменьшал его до 640×480 и кодировал заново в JPEG. Параметр -q:v 8 — квантователь FFmpeg, а не «качество 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 Мбит/с. До TCP, TLS и WebSocket наши десять JPEG уже съели весь бюджет. Можно ещё ухудшить JPEG. Можно вспомнить, что камера сама кодирует видео.
JPEG сжимает каждое изображение отдельно. H.264 использует опорные кадры и предсказание изменений между ними. На нашем роботе он уже работал внутри камеры; мы просто выбрасывали результат этой работы и делали фотографии заново.
Получился такой тракт:
H46: H.264 → RTSP/TCP → Python/PyAV на роботе → fMP4
→ отдельный WebSocket → сервер Fleet → HTTP video/mp4
→ MediaSource → обычный <video> в браузере
На борту используем PyAV 16.0.1, Python-библиотеку над FFmpeg, и websockets. Серверная часть — FastAPI, интерфейс — React/TypeScript. H.264 повторно не кодируем: PyAV меняет упаковку. Поворот передней и задней картинки вынесли в CSS: rotate(90deg), с перестановкой ширины и высоты видеоблока. При потоке 1280×720 оператор получает правильно ориентированное портретное изображение. Сжатые пакеты при этом не меняются. Ради механически повёрнутой камеры больше не нужно декодировать и кодировать весь эфир на бортовом CPU.

Настройка применилась. Кроме той части, ради которой её меняли
Для нашего браузерного плеера выбрали H.264, 1280×720 и ключевой кадр примерно раз в секунду. Прежний GOP был около четырёх секунд: подключившись между ключевыми кадрами, зритель мог долго ждать картинку.
ONVIF SetVideoEncoderConfiguration отвечал успехом, но H46 сохранял четырёхсекундный GOP. Поэтому ONVIF оставили для поиска URI, а настройки меняем родным API камеры: /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 камера приводила к 25 FPS. Поэтому в результате фигурируют реальные 25, а не заказанные 30. Ночью в прежних опытах источник выдавал около 10 FPS; дневная настройка сама по себе не сокращает выдержку.
Битрейт снижали ступенями: 800 → 256 → 128 кбит/с. При 256 кбит/с первый ключевой фрагмент передней камеры занимал 24 622 байта, при 128 — 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. Ограничиваем предварительный анализ, чтобы быстрее получить первый кадр; FPS отдельно считаем по PTS. Слишком маленький 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 позволяет сразу отдать начальные метаданные MP4, default_base_moof делает фрагменты независимыми от абсолютных смещений файла. Единицы и поведение флагов описаны в документации muxer MP4 в FFmpeg.
Здесь две особенности нашего H46. При старте RTP с нуля libav терял PTS/DTS первого ключевого пакета; для этого случая восстанавливаем ноль. И только после получения ключевого кадра создаём выходной поток: после перенастройки камеры параметры H.264 SPS/PPS могли появиться лишь в нём. Раньше скопировали контекст — остались без нужных параметров.
TCP тоже умеет делать архив
Даже готовое видео сначала запаздывало. В 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-кадр и продолжить со следующего нельзя: ему могут понадобиться удалённые данные.
Эта правка ограничила накопление, но сама по себе плавный эфир ещё не дала. Профиль 800 кбит/с и ошибки плеера никуда не делись.
Браузер убирал за собой слишком хорошо
На клиенте используем MediaSource, addSourceBuffer('video/mp4; codecs="' + codec + '"') и appendBuffer(), ожидая updateend перед следующей операцией. Строку avc1.… извлекаем из avcC начального MP4-сегмента. Декодирование и показ оставляем обычному <video>.
Но «оставить обычному видео» тоже можно испортить. Мы удаляли историю старше двух секунд через SourceBuffer.remove(). В MSE удаление учитывает зависимости кадров и может дойти до следующей точки произвольного доступа. С четырёхсекундным GOP уборка захватывала данные, которые плеер ещё показывал. Это поведение есть в алгоритме удаления кадров MSE. Мы познакомились с ним через зависшую картинку.
Оставили 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 обязательна для этого плеера: без неё новая порция данных могла запускать очередную перемотку до завершения предыдущей. Плеер всё время догонял эфир, показывать было некогда. После исправления обычное воспроизведение идёт само; догоняем только при заметном отставании.
А какой битрейт получился в 4G и 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 кбит/с |
Автоматического выбора битрейта «под поколение связи» здесь нет. 128 кбит/с мы выставили сами ещё до заключительного перехода модема в 3G. В первой строке измерена доступная отдача тестовому HTTPS-серверу, во второй — расход уже работающего видео. Максимальную скорость 3G этим опытом не измеряли.
Откуда 142: на входе медиарелея за 10,0099 с приняли 177 194 байта, то есть 177194 × 8 / 10,0099 ≈ 141615 бит/с. Здесь учтена передаваемая упаковка видеоканала. Полный расход мобильного интерфейса с TCP/TLS и прочими соединениями робота будет отличаться. Поэтому значение bitrate=128 в камере не подменяет сетевой замер.
Проверяли и сам радиорежим. В Linux для такой проверки пригодятся mmcli -L, затем mmcli -m 0: важна фактическая access tech, а не надпись LTE на корпусе модема. В нашем заключительном окне сохранили 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. Число «25» из меню камеры в таблицу не переписывали.

В этих просмотрах многосекундных остановок уже не было. Открывать каждую камеру меньше чем за секунду пока не получилось.
Если повторять такой разбор на другом роботе, я бы начал с трёх вещей: снять реальные PTS и размеры пакетов, проверить применённые настройки камеры, посмотреть очередь отправки. Затем уже заниматься плеером.
Все 10 историй SpeedyBot
Робот доставщик дороже автомобиля, мокрая трава и открытый JDWP
25 FPS с видеокамеры по 3G. Модем, извини — вы здесь.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.