Небольшая предыстория
Я занимаюсь разработкой роботов (как хобби) уже долгое время, и столкнулся с проблемой передачи видео через интернет со своего 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.