Daily MaverickTITLE BECKONS: Maiden Formula One title in sight for phenom Kimi AntonelliPunchFG promises intervention in Zaria railway compensation disputeInquirer4 Commonwealth Avenue lanes to close Oct. 24-25 for MRT-7 worksBollywood HungamaTiger Shroff team shares video of his injuries after Juhu firing incident; actor reportedly sustained cuts and bruisesZDF heuteAktuelle Pressemitteilungen des ZDFIl Fatto QuotidianoPrete muore durante la messa in diretta social: un grande crocifisso di legno appeso alle sue spalle si stacca e lo colpisce alla testaQuemBarbara Palvin mostra filha como 'Angel' após seu desfile pela Victoria's SecretObservador DesportoNão tem casa? Afinal basta emigrar...RTL BoulevardPrins William als eerste royal te gast in talkshow Graham NortonANSASan Patrignano, Meloni: 'Da irresponsabili ostinarsi a definire la cannabis droga leggera'RapplerGot a story to share? Be a Cebuano Mover!France 24Ivory Coast: Children face serious health risks from lead in paint
The Daily Newsstand · Free, Always
Friday, October 23, 2026

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

Translate

У 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 →

SpeedyBot: 25 FPS по 3G

SpeedyBot: 25 FPS по 3G

Что у робота под кожухом

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

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

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

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

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

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

IP-модуль семейства 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. В 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 с открытым грузовым отсеком

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

Наш SpeedyBot. Камеры работают внутри его локальной сети; в мобильный канал отправляем видео выбранного направления.

Почему десять JPEG не помещались в 4G

Наш первый обработчик декодировал RTSP, поворачивал кадр у передней и задней камер, уменьшал его до 640×480 и кодировал заново в JPEG. Параметр -q:v 8 — квантователь FFmpeg, а не «качество 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 Мбит/с. До 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.

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

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

Настройка применилась. Кроме той части, ради которой её меняли

Для нашего браузерного плеера выбрали 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» из меню камеры в таблицу не переписывали.

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

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

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

Если повторять такой разбор на другом роботе, я бы начал с трёх вещей: снять реальные PTS и размеры пакетов, проверить применённые настройки камеры, посмотреть очередь отправки. Затем уже заниматься плеером.

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

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

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

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

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

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

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

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

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

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

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

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.