Небольшая предыстория

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

Вот пара метрик -

1280х720 видео, все процессы запущены. Робот подключен к интернету и выполняется код на стороне робота (доп нагрузка)
1280х720 видео, все процессы запущены. Робот подключен к интернету и выполняется код на стороне робота (доп нагрузка)
1280х720. Робот не выполняет код
1280х720. Робот не выполняет код

Стоит также отметить, что изменение разрешения видео (что очевидно) влияет на загрузку.

Этот код также был протестирован на Raspberry Pi Zero 2.

P.S — кажется, что для меня это решение единственное, которое имеет низкую загрузку процессора и которое также позволяет добавлять различный функционал.

В планах - использовать TensorflowJS.

Комментарии (25)


  1. V1tol
    11.07.2024 09:52

    Интересно было бы попробовать встроенную в GStreamer поддержку WebRTC, чтобы избавиться от промежуточного браузера.


    1. don_alex_88 Автор
      11.07.2024 09:52

      Тоже читал про это. Но тут уже вопрос как мне на стороне распберри вытащить стрим. Если в той же ноде - но скорей всего опять будет загрузка процессора. Стоит в листе тудушек в любом случае =)


  1. gudvinr
    11.07.2024 09:52

    Правильно я понимаю, что вы изначально использовали непроизводительное решение и вместо того, чтобы разобраться, почему оно непроизводительное и поискать другие решения, запустили браузер?

    Чроме же не на волшебстве работает, и раз его движок показывает приемлемую производительность, то это не проблема RPi, а проблема имплементации, которую вы использовали. А раз это проблема aioRTC, значит можно просто другое средство использовать.

    Когда каждый текстовый редактор тащит за собой браузер, к этому все привыкли.
    Но тащить целый браузер в эмбед, когда вам надо только стрим сделать, это какая-то дичь.

    Для пишек можно либо просто μStreamer использовать (если webrtc не обязателен), либо Janus попробовать.


    1. don_alex_88 Автор
      11.07.2024 09:52

      Для ноды есть библиотека wrtc которая позволяет осуществлять соединение. Которая как раз и грузила процессор. Я все расписывал в прошлой статье


  1. 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%.


    1. don_alex_88 Автор
      11.07.2024 09:52

      Интересно, у меня mjpeg был сильно тормозной, особенно по вебу (как грузил проц уже не помню). С другой стороны в интернетах пишут что наименьшая задержка у webrtc


      1. 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
        


        1. don_alex_88 Автор
          11.07.2024 09:52

          Интересно попробывать еще раз =) С картинкой ничего не делал (пока что)

          На пи можно сразу стрим мпег сделать и его слать по вебу (но интересно задержка какая)


  1. slimper
    11.07.2024 09:52

    Тоже сталкивался с подобными задачами, в итоге сделал решение на базе rtsp через mediamtx. Только им удалось на первом zero w добиться хорошего fps при 720p. Нагрузка при этом была в районе 60%. Работало стабильно для использования в качестве круглосуточной камеры наблюдения. WebRTC, кстати тоже поддерживается mediamtx.


    1. don_alex_88 Автор
      11.07.2024 09:52

      Слышал про такую штуку, не пробывал. Не знаю как с ним можно еще параллельно получить доступ к видео стриму (локально создавать коннекшн еще один?), например для передачи в Tensorflow.


      1. slimper
        11.07.2024 09:52

        Никаких проблем с локальной доступностью быть не должно. В примере, что я смотрел, для теста используют команду ffplay rtsp://raspberrypi.local:8554/cam .

        Другой вопрос, насколько zero хватит еще и на TF. Я лично выносил обработку потока с камеры zero нейронкой (кстати с usb ускорителем coral) на внешнюю rpi4.


  1. misha_ruchk0
    11.07.2024 09:52

    Может стоило взглянуть на mjpeg (https://en.wikipedia.org/wiki/Motion_JPEG)?


    1. buldo
      11.07.2024 09:52

      Там на самом деле разницы никакой не будет. Энкодинг выполняют аппаратные блоки - процу остаётся только шифрование для webrtc поверх замутить и всё.

      Так что в теории, mjpeg даст больше трафика и большую нагрузку по шифрованию


      1. misha_ruchk0
        11.07.2024 09:52

        В моем случае RPi3 без проблем справлялась с отправкой нескольких фрэймов в секунду (без шифрования). И главное, не нужно было городить весь этот огород с WEBRTC, который (к тому моменту) не так давно появился на горизонте.


        1. buldo
          11.07.2024 09:52

          Даже zero2w успевает кодировать 720p@60


          1. misha_ruchk0
            11.07.2024 09:52

            zero2w имеет более мощный CPU чем RPi3, если я не ошибаюсь. да и не было zero в 16-ом году.


      1. freedbrt
        11.07.2024 09:52

        Откуда информация? В WebRTC используется VP8, и библиотека libvpx которая полностью софтварная. Блоков доя аппаратного енкодинга vp8 на rpi нет


        1. buldo
          11.07.2024 09:52

          WebRTC может использовать любой кодек о котором договорятся пиры.


  1. buldo
    11.07.2024 09:52

    Чёт по ощущениям - слишком большая нагрузка.

    Я тоже делал что-то подобное на .NET - взял довольно известную либу и немного соптимизоровал.

    https://github.com/buldo/RtpToWebRtcRestreamer

    Правда по дороге выкинуд часть кода со stun и тп и моё поделие работает только если сервер и клиент в одной сети :)


    1. don_alex_88 Автор
      11.07.2024 09:52

      Последнее - самое как раз важное для меня =) Ибо когда мджпег не через локалку, там уже достаточно большая задержка.

      Самое продуктивное здесь, что пробывал и гуглил - это WebRTC.


      1. buldo
        11.07.2024 09:52

        Так и у меня в примере не mjpeg, a h264.

        Проблемы с локалкой обычно решаются zerotier.

        Вообще, если нужно быстро и без браузера, то gstreamer на клиенте и сервере. Если нужно ещё быстрее, то здравствуй C++.

        Но с другой стороны, камеры и энкодер на raspberry жутко медленные - они дают большую часть задержки


  1. buldo
    11.07.2024 09:52

    Рекомендую переходить на rk3568 или rk3588, если хотите задержку менее 80-100мс


    1. don_alex_88 Автор
      11.07.2024 09:52

      О про такие даже не знал


  1. arthurpro
    11.07.2024 09:52

    я бы заменил nodejs на компилируемый язык типа Go, что позволит еще больше сэкономить ресурсы raspberry pi


    1. don_alex_88 Автор
      11.07.2024 09:52

      node только страницу отдает и порт слушает +-