Небольшая предыстория
Я занимаюсь разработкой роботов (как хобби) уже долгое время, и столкнулся с проблемой передачи видео через интернет со своего Raspberry PI 4 и Raspberry PI zero.
Сначала идея была в реализации WebRTC на node js, про что я написал в этой статье. Как было написано, проблема заключалась в высокой загрузке процессора.
WebRTC и Ghrome.
Chrome имеет высокую производительность, особенно его реализация WebRTC это что то.
В какое то время мне попалась статья на медиуме, в которой поднимался такой же вопрос, который меня мучает уже несколько лет — ссылка.
Довольно странный способ, но если перфоманс действительно такой, то почему бы и нет?
Реальная ситуация
После проверки этого способа возникла уже другая проблема - хромиум не видит камеру. так как версия ОС другая, плюс прошло уже немало времени. В добавок ко всему этому, способ, описанный у linux-project уже не работает так как поменялась апи камеры в Raspberian.
Но и тут можно решить эту проблему - создав виртуальную камеру, используя gststreamer, про это хорошо написано в этом топике.
Пример рабочего решения
Итак, решение, которое я собрал воедино, следующее:
Создаем виртуальную камеру, используя gststreamer
Запускаем localhost, который будет отдавать только веб страницу (можно также в нем реализовать сокет подключение и для передачи сигналов WebRTC и т. п.). Для тестирования буду использовать этот сервис и для передачи веб страницы для тестирования
Запускаем chromium-browser который будет переходить на страницу сервиса, создающего WebRTC
Тестируем и радуемся!
Создание виртуальной камеры
Для начала, устанавливаем gststreamer:
sudo apt-get install -y gstreamer1.0-tools gstreamer1.0-plugins gstreamer1.0-libcamera
Далее необходимо установить сервис v4l2loopback-dkms и активировать его:
sudo apt-get install -y v4l2loopback-dkms
Открываем файл
sudo nano /etc/modules-load.d/v4l2loopback.conf
И добавляем в него v4l2loopback
Теперь необходимо создать виртуальную камеру. Для этого открываем файл
sudo nano /etc/modprobe.d/v4l2loopback.conf
и добавляем туда
options v4l2loopback video_nr=8
options v4l2loopback card_label="Chromium device"
options v4l2loopback exclusive_caps=1
где video_nr=8
это номер видео девайса. Если в системе используется, укажите другой
Перезагружаем систему и проверяем ls /dev/ - тут в списке должна быть камера под указанным номером.
Для запуска виртуальной камеры используем команду:
gst-launch-1.0 libcamerasrc ! "video/x-raw,width=1280,height=1080,format=YUY2",interlace-mode=progressive ! videoconvert ! v4l2sink device=/dev/video8
И теперь можем получить Raspberry PI камеру из под хромиума.
Создание сервиса WebRTC
Для создания сервиса я так же буду использовать node js.
Мне также понадобится сокет соединение для передачи сигналов между пирами.
Код сервиса:
const path = require("path");
const express = require("express");
const app = express();
const server = require("http").createServer(app);
const { Server } = require("socket.io");
const io = new Server(server, {
cors: {
origin: true,
methods: ["GET", "POST"],
transports: ["polling", "websocket"],
},
allowEIO3: true,
path: "/api/socket/",
});
const port = process.env.PORT || 3001;
//Здесь отдаем скрипты
app.use('/static', express.static(path.join(__dirname, 'src/public')))
app.use('/static_web', express.static(path.join(__dirname, 'src_web/public')))
// Отдаем страницу сервиса, которая запусукается в хромиуме
app.get("/service", function (req, res) {
console.log('service')
res.sendFile(path.join(__dirname, './src/index.html'));
});
//Отдаем тестовую страницу
app.get("/main", function (req, res) {
console.log('main')
res.sendFile(path.join(__dirname, './src_web/index.html'));
});
server.listen(port);
let serviceSocketId = null;
let webSocketId = null;
io.on("connection", (socket) => {
//Эта часть для инициализации коммуникации сервис - клиент
console.log("connect");
socket.on("init_service", (message) => {
serviceSocketId = socket.id;
});
socket.on("init_web", (message) => {
webSocketId = socket.id;
});
socket.on("message_from_service", (message) => {
console.log('message_from_service', message);
socket.to(webSocketId).emit("signal_to_web", message);
});
socket.on("message_from_web", (message) => {
console.log('message_from_web', message);
socket.to(serviceSocketId).emit("signal_to_service", message);
});
});
HTML будет выглядеть таким образом:
Сервис -
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
</head>
<body>
</body>
<script src="./simplepeer.min.js"></script>
<script type="importmap">
{
"imports": {
"socket.io-client": "https://cdn.socket.io/4.7.2/socket.io.esm.min.js",
"simple-peer": "https://cdnjs.cloudflare.com/ajax/libs/simple-peer/9.11.1/simplepeer.min.js"
}
}
</script>
<script src="./static/script.js" type="module"></script>
</html>
Клиент (веб тестовая страница) -
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
</head>
<body>
<video id="localVideo" autoplay muted="muted"></video>
</body>
<script type="importmap">
{
"imports": {
"socket.io-client": "https://cdn.socket.io/4.7.2/socket.io.esm.min.js",
"simple-peer": "https://cdnjs.cloudflare.com/ajax/libs/simple-peer/9.11.1/simplepeer.min.js"
}
}
</script>
<script src="./static_web/script.js" type="module"></script>
</html>
Как видно, разница только в video тэге.
Сами скрипты -
import { io } from "socket.io-client";
const socket = io('http://localhost:3001', {
path: '/api/socket/',
});
let config = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' }
]
};
const peer = new RTCPeerConnection(config);
socket.on('connect', () => {
socket.emit('init_service');
socket.on('signal_to_service', async (message) => {
if (message.offer) {
await peer.setRemoteDescription(new RTCSessionDescription(message.offer));
const answer = await peer.createAnswer();
await peer.setLocalDescription(answer);
socket.emit('message_from_service', { answer });
navigator.mediaDevices.getUserMedia({ video: true }).then((stream) => {
peer.addStream(stream);
});
}
if (message.answer) {
await peer.setRemoteDescription(message.answer);
}
if (message.iceCandidate) {
await peer.addIceCandidate(message.iceCandidate);
}
});
})
peer.onicecandidate = (event) => {
socket.emit("message_from_service", { iceCandidate: event.candidate });
};
И скрипт веб страницы -
import { io } from "socket.io-client";
// тут необходимо указать локальный ip адресс, если тестируется не на Raspberry PI
const socket = io('http://localhost:3001', {
path: '/api/socket/',
});
let config = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' }
]
};
const peer = new RTCPeerConnection(config);
socket.on("signal_to_web", async (message) => {
if (message.answer) {
await peer.setRemoteDescription(message.answer);
}
if (message.iceCandidate) {
await peer.addIceCandidate(message.iceCandidate);
}
});
peer.onicecandidate = (event) => {
socket.emit("message_from_web", { iceCandidate: event.candidate });
};
peer.ontrack = (event) => {
const video = document.getElementById('localVideo');
if (video) {
video.srcObject = event.streams[0];
video.play();
}
};
const init = async () => {
const offer = await peer.createOffer({ offerToReceiveVideo: true, });
await peer.setLocalDescription(offer);
socket.emit("message_from_web", { offer });
};
socket.on('connect', () => {
// После подключения к серверу, инициализируем пользователя и
// отправляем оффер
socket.emit('init_web');
init();
})
После создания всех необходимых файлов и запуска сервисов, можно запустить хромиум.
Тут важно отметить, что его можно запускать не только с GUI!
chromium-browser --no-sandbox --headless --use-fake-ui-for-media-stream --remote-debugging-port=9222 http://localhost:3001/service
После этого можно перейти по адресу — localhost:3001/main или <RaspberryPI-IP>:3001/main и через какое то время должно появиться видео.
Что касаемо производительность — она много лучше, чем в моей первой реализации чисто на node js.
Вот пара метрик -
Стоит также отметить, что изменение разрешения видео (что очевидно) влияет на загрузку.
Этот код также был протестирован на Raspberry Pi Zero 2.
P.S — кажется, что для меня это решение единственное, которое имеет низкую загрузку процессора и которое также позволяет добавлять различный функционал.
В планах - использовать TensorflowJS.
Комментарии (25)
gudvinr
11.07.2024 09:52Правильно я понимаю, что вы изначально использовали непроизводительное решение и вместо того, чтобы разобраться, почему оно непроизводительное и поискать другие решения, запустили браузер?
Чроме же не на волшебстве работает, и раз его движок показывает приемлемую производительность, то это не проблема RPi, а проблема имплементации, которую вы использовали. А раз это проблема aioRTC, значит можно просто другое средство использовать.
Когда каждый текстовый редактор тащит за собой браузер, к этому все привыкли.
Но тащить целый браузер в эмбед, когда вам надо только стрим сделать, это какая-то дичь.Для пишек можно либо просто μStreamer использовать (если webrtc не обязателен), либо Janus попробовать.
don_alex_88 Автор
11.07.2024 09:52Для ноды есть библиотека wrtc которая позволяет осуществлять соединение. Которая как раз и грузила процессор. Я все расписывал в прошлой статье
Fox_exe
11.07.2024 09:52А зачем именно WebRTC?
Можно ведь просто чутьли не напрямую слать MJpeg поток с камеры в браузер (Большинство китайских камер вообще ничего кроме mjpeg не могут даже через USB). В таком случае даже ничего перекодировать не нужно - достаточно сказать камере какое разрешение и FPS вам надо - она сама переключить поток на нужный. Остаётся только эти байты переслать в браузер клиенту "как есть".Лично я давно использую связку из Python3 + Linuxpy + Flask
Linuxpy - только для удобства работы с камерой. По факту можно вообще напрямую с ней общаться через v4l2
Flask - в качестве веб-сервера. Можно и FastAPI или любой другой сервер использовать. Смотря какой доп. функционал вам нужен прямо в браузере.Заметка: Orange PI Zero 2 даже по WiFi даёт задержку 200-500мс с таким сервером, нагружая процессор менее чем на 10%.
don_alex_88 Автор
11.07.2024 09:52Интересно, у меня mjpeg был сильно тормозной, особенно по вебу (как грузил проц уже не помню). С другой стороны в интернетах пишут что наименьшая задержка у webrtc
Fox_exe
11.07.2024 09:52Вы, возможно, пытались как-то обрабатывать картинку (менять разрешение, кол-во кадров, цветовой формат и т.п.), отсюда и куча тормозов (процессор с подобным справляется весьма неважно).
В моём-же случае - мы берём готовые байты (jpeg) и отсылаем их сразу клиенту в браузер. Без обработки, без вмешательства, лиш добавляется немного нагрузки в виде работы с HTTP протоколом.
https://pypi.org/project/linuxpy/ - Пробуйте. Там прямо в описании есть готовый пример с Flask.
А вот так задаются разрешение, формат и фреймрейт
from linuxpy.video.device import Device as VideoDev, BufferType cam = VideoDev.from_id(1) # /dev/video1 cam.open() cam.set_format(BufferType.VIDEO_CAPTURE, 1920, 1080, "MJPG") # MJPG or YUYV cam.set_fps(BufferType.VIDEO_CAPTURE, 30) # 30 FPS
don_alex_88 Автор
11.07.2024 09:52Интересно попробывать еще раз =) С картинкой ничего не делал (пока что)
На пи можно сразу стрим мпег сделать и его слать по вебу (но интересно задержка какая)
slimper
11.07.2024 09:52Тоже сталкивался с подобными задачами, в итоге сделал решение на базе rtsp через mediamtx. Только им удалось на первом zero w добиться хорошего fps при 720p. Нагрузка при этом была в районе 60%. Работало стабильно для использования в качестве круглосуточной камеры наблюдения. WebRTC, кстати тоже поддерживается mediamtx.
don_alex_88 Автор
11.07.2024 09:52Слышал про такую штуку, не пробывал. Не знаю как с ним можно еще параллельно получить доступ к видео стриму (локально создавать коннекшн еще один?), например для передачи в Tensorflow.
slimper
11.07.2024 09:52Никаких проблем с локальной доступностью быть не должно. В примере, что я смотрел, для теста используют команду ffplay rtsp://raspberrypi.local:8554/cam .
Другой вопрос, насколько zero хватит еще и на TF. Я лично выносил обработку потока с камеры zero нейронкой (кстати с usb ускорителем coral) на внешнюю rpi4.
misha_ruchk0
11.07.2024 09:52Может стоило взглянуть на mjpeg (https://en.wikipedia.org/wiki/Motion_JPEG)?
buldo
11.07.2024 09:52Там на самом деле разницы никакой не будет. Энкодинг выполняют аппаратные блоки - процу остаётся только шифрование для webrtc поверх замутить и всё.
Так что в теории, mjpeg даст больше трафика и большую нагрузку по шифрованию
misha_ruchk0
11.07.2024 09:52В моем случае RPi3 без проблем справлялась с отправкой нескольких фрэймов в секунду (без шифрования). И главное, не нужно было городить весь этот огород с WEBRTC, который (к тому моменту) не так давно появился на горизонте.
buldo
11.07.2024 09:52Даже zero2w успевает кодировать 720p@60
misha_ruchk0
11.07.2024 09:52zero2w имеет более мощный CPU чем RPi3, если я не ошибаюсь. да и не было zero в 16-ом году.
buldo
11.07.2024 09:52Чёт по ощущениям - слишком большая нагрузка.
Я тоже делал что-то подобное на .NET - взял довольно известную либу и немного соптимизоровал.
https://github.com/buldo/RtpToWebRtcRestreamer
Правда по дороге выкинуд часть кода со stun и тп и моё поделие работает только если сервер и клиент в одной сети :)
don_alex_88 Автор
11.07.2024 09:52Последнее - самое как раз важное для меня =) Ибо когда мджпег не через локалку, там уже достаточно большая задержка.
Самое продуктивное здесь, что пробывал и гуглил - это WebRTC.
buldo
11.07.2024 09:52Так и у меня в примере не mjpeg, a h264.
Проблемы с локалкой обычно решаются zerotier.
Вообще, если нужно быстро и без браузера, то gstreamer на клиенте и сервере. Если нужно ещё быстрее, то здравствуй C++.
Но с другой стороны, камеры и энкодер на raspberry жутко медленные - они дают большую часть задержки
buldo
11.07.2024 09:52Рекомендую переходить на rk3568 или rk3588, если хотите задержку менее 80-100мс
arthurpro
11.07.2024 09:52я бы заменил nodejs на компилируемый язык типа Go, что позволит еще больше сэкономить ресурсы raspberry pi
V1tol
Интересно было бы попробовать встроенную в GStreamer поддержку WebRTC, чтобы избавиться от промежуточного браузера.
don_alex_88 Автор
Тоже читал про это. Но тут уже вопрос как мне на стороне распберри вытащить стрим. Если в той же ноде - но скорей всего опять будет загрузка процессора. Стоит в листе тудушек в любом случае =)