Video-streaming в Raspberry PI + WebRTC — победа?
Небольшая предыстория
Я занимаюсь разработкой роботов (как хобби) уже долгое время, и столкнулся с проблемой передачи видео через интернет со своего Raspberry PI 4 и Raspberry PI zero.
Сначала идея была в реализации WebRTC на node js, про что я написал в этой статье — https://habr.com/ru/articles/749550/. Как было написано, проблема заключалась в высокой загрузке процессора.
WebRTC и Ghrome.
Chrome имеет высокую производительность, особенно его реализация WebRTC это что то.
В какое то время мне попалась статья на медиуме, в которой поднимался такой же вопрос, который меня мучает уже несколько лет — https://medium.com/@marcus2vinicius/webrtc-unlocking-high-performance-on-raspberry-server-with-javascript-for-3g-4g-connections-8d1048bc12ff
Довольно странный способ, но если перфоманс действительно такой, то почему бы и нет?
Реальная ситуация
После проверки этого способа возникла уже другая проблема — хромиум не видит камеру. так как версия ОС другая, плюс прошло уже немало времени. В добавок ко всему этому, способ, описанный у linux-project уже не работает так как поменялась апи камеры в Raspberian.
Но и тут можно решить эту проблему — создав виртуальную камеру, используя gststreamer, про это хорошо написано в этом топике — https://forums.raspberrypi.com/viewtopic.php? t=359204
Пример рабочего решения
Итак, решение, которое я собрал воедино, следующее —
Создаем виртуальную камеру, используя 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 будет выглядеть таким образом:
Сервис —
Клиент (веб тестовая страница) —
Как видно, разница только в 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 или
Что касаемо производительность — она много лучше, чем в моей первой реализации чисто на node js.
Вот пара метрик -
1280×720 видео, все процессы запущены. Робот подключен к интернету и выполняется код на стороне робота (доп нагрузка)
1280×720. Робот не выполняет код
Стоит также отметить, что изменение разрешения видео (что очевидно) влияет на загрузку.
Этот код также был протестирован на Raspberry Pi Zero 2.
P.S — кажется, что для меня это решение единственное, которое имеет низкую загрузку процессора и которое также позволяет добавлять различный функционал.
В планах — использовать TensorflowJS.