Простой zero-copy рендеринг аппаратно ускоренного видео в QML

в 10:51, , рубрики: c++, egl, gstreamer, opengles, QML, qt

Введение

Целью данной статьи является продемонстрировать способ как можно подружить сторонние видео буфера и QML. Основная идея — использовать стандартный QML компонент VideoOutput. Он позволяет подсовывать сторонние источники, неплохо документирован и имеет бекэнд поддерживающий GL_OES_EGL_image_external.

Мысль, что это вдруг может быть полезно, возникла после того, как я попытался запустить примеры работы с камерой в Qt, и на embedded платформе они работали со скоростью 3-5 кадра в секунду. Стало ясно, что из коробки ни о каком zero-copy речи не идет, хотя платформа все это отлично поддерживает. Справедливости ради, на десктопе VideoOutput и Camera работают, как и положено, быстро и без лишних копирований. Но в моей задаче, увы, нельзя было обойтись существующими классами для захвата видео, и хотелось завести видео из стороннего источника, каким может быть произвольный GStreamer пайплайн для декодирования видео, к примеру, из файла или RTSP стрим, или сторонний API который интегрировать в базовые классы Qt несколько сомнительно. Можно еще, конечно, в очередной раз переизобрести велосипед и написать свой компонент с рисованием через OpenGL, но это сразу показалось заведомо тупиковым и сложным путем.

Все вело к тому, что нужно разобраться как же оно устроено на самом деле, и написать небольшое приложение подтверждающее теорию.

Теория

VideoOutput поддерживает пользовательские source, при условии что

  1. переданный объект может принять QAbstractVideoSurface напрямую через свойство videoSurface
  2. или через mediaObject с контролом QVideoRendererControl [ссылка].

Поиск по исходникам и документации показал, что в QtMultimedia есть класс QAbstractVideoBuffer который поддерживает различные типы хэндлов, начиная от QPixmap и заканчивая GLTexture и EGLImage. Дальнейшие поиски привели к плагину videonode_egl, который отрисовывает пришедший ему кадр при помощи шейдера с samplerExternalOES. Это означает, что после того как мне удасться создать QAbstractVideoBuffer с EGLImage, остается найти способ передать этот буфер в videnode_egl.
А если EGLImage платформой не поддерживается, то можно обернуть пришедшую память и отправить на отрисовку, благо шейдеры для большинства пиксельных форматов уже реализованы.

Реализация

Пример почти целиком основывается на туториале Video Overview.

Для того чтобы Qt заработало с OpenGL ES на десктопе, необходимо пересобрать Qt с соответствующим флагом. По умолчанию, для десктопа он не включен.

Для простоты мы воспользуемся первым способом, а в качестве источника видео возьмем простой GStreamer пайплайн:

v4l2src ! appsink

Создадим класс V4L2Source, который будет поставлять фреймы в заданную ему QAbstractVideoSurface.

class V4L2Source : public QQuickItem
{
    Q_OBJECT
    Q_PROPERTY(QAbstractVideoSurface* videoSurface READ videoSurface WRITE
                   setVideoSurface)
    Q_PROPERTY(QString device MEMBER m_device READ device WRITE setDevice)
    Q_PROPERTY(QString caps MEMBER m_caps)
public:
    V4L2Source(QQuickItem* parent = nullptr);
    virtual ~V4L2Source();

    void setVideoSurface(QAbstractVideoSurface* surface);
    void setDevice(QString device);

public slots:
    void start();
    void stop();

private slots:
    void setWindow(QQuickWindow* win);
    void sync();

signals:
    void frameReady();
...
}

Все достаточно тривиально, кроме слота setWinow() — он нужен чтобы перехватить сигнал QQuickItem::windowChanged() и установить callback на QQuickWindow::beforeSynchronizing().

Так как бэкэнд VideoOutput не всегда умеет работать с EGLImage, то необходимо запросить у QAbstractVideoSurface какие форматы для заданного QAbstractVideoBuffer::HandleType она поддерживает:

void V4L2Source::setVideoSurface(QAbstractVideoSurface* surface)
{
    if (m_surface != surface && m_surface && m_surface->isActive()) {
        m_surface->stop();
    }
    m_surface = surface;
    if (surface
            ->supportedPixelFormats(
                QAbstractVideoBuffer::HandleType::EGLImageHandle)
            .size() > 0) {
        EGLImageSupported = true;
    } else {
        EGLImageSupported = false;
    }

    if (m_surface && m_device.length() > 0) {
        start();
    }
}

Создадим наш пайплайн, и установим необходимые callback'и:

GstAppSinkCallbacks V4L2Source::callbacks = {.eos = nullptr,
                                             .new_preroll = nullptr,
                                             .new_sample =
                                                 &V4L2Source::on_new_sample};

V4L2Source::V4L2Source(QQuickItem* parent) : QQuickItem(parent)
{
    m_surface = nullptr;
    connect(this, &QQuickItem::windowChanged, this, &V4L2Source::setWindow);

    pipeline = gst_pipeline_new("V4L2Source::pipeline");
    v4l2src = gst_element_factory_make("v4l2src", nullptr);
    appsink = gst_element_factory_make("appsink", nullptr);

    GstPad* pad = gst_element_get_static_pad(appsink, "sink");
    gst_pad_add_probe(pad, GST_PAD_PROBE_TYPE_QUERY_BOTH, appsink_pad_probe,
                      nullptr, nullptr);
    gst_object_unref(pad);

    gst_app_sink_set_callbacks(GST_APP_SINK(appsink), &callbacks, this,
                               nullptr);

    gst_bin_add_many(GST_BIN(pipeline), v4l2src, appsink, nullptr);
    gst_element_link(v4l2src, appsink);

    context = g_main_context_new();
    loop = g_main_loop_new(context, FALSE);
}

void V4L2Source::setWindow(QQuickWindow* win)
{
    if (win) {
        connect(win, &QQuickWindow::beforeSynchronizing, this,
                &V4L2Source::sync, Qt::DirectConnection);
    }
}

GstFlowReturn V4L2Source::on_new_sample(GstAppSink* sink, gpointer data)
{
    Q_UNUSED(sink)
    V4L2Source* self = (V4L2Source*)data;
    QMutexLocker locker(&self->mutex);
    self->ready = true;
    self->frameReady();
    return GST_FLOW_OK;
}

// Request v4l2src allocator to add GstVideoMeta to buffers
static GstPadProbeReturn
appsink_pad_probe(GstPad* pad, GstPadProbeInfo* info, gpointer user_data)
{
    if (info->type & GST_PAD_PROBE_TYPE_QUERY_BOTH) {
        GstQuery* query = gst_pad_probe_info_get_query(info);
        if (GST_QUERY_TYPE(query) == GST_QUERY_ALLOCATION) {
            gst_query_add_allocation_meta(query, GST_VIDEO_META_API_TYPE, NULL);
        }
    }
    return GST_PAD_PROBE_OK;
}

В конструкторе стандартный код для запуска своего пайплайна, создается GMainContext и GMainLoop чтобы завести пайплайн в отдельном потоке.

Стоит обратить внимание на флаг Qt::DirectConnection в setWindow() — он гарантирует что callback будет вызван в том же потоке что и сигнал, что дает нам доступ к текущему OpenGL контексту.

V4L2Source::on_new_sample() который вызывается когда новый кадр из v4l2src приходит в appsink просто выставляет флаг готовности и вызывает соответствующий сигнал, чтобы проинформировать VideoOutput что требуется перерисовать содержимое.

Пробник на sink паде у appsink необходим, чтобы попросить аллокатор v4l2src добавлять мета информацию о видео формате к каждому буферу. Это необходимо чтобы учесть ситуации, когда драйвер выдает видео буфер со страйдом/оффсетом отличными от стандартных.

Обновление видеокадра для VideoOutput происходит в слоте sync():

// Make sure this callback is invoked from rendering thread
void V4L2Source::sync()
{
    {
        QMutexLocker locker(&mutex);
        if (!ready) {
            return;
        }
        // reset ready flag
        ready = false;
    }
    // pull available sample and convert GstBuffer into a QAbstractVideoBuffer
    GstSample* sample = gst_app_sink_pull_sample(GST_APP_SINK(appsink));
    GstBuffer* buffer = gst_sample_get_buffer(sample);
    GstVideoMeta* videoMeta = gst_buffer_get_video_meta(buffer);

    // if memory is DMABUF and EGLImage is supported by the backend,
    // create video buffer with EGLImage handle
    videoFrame.reset();
    if (EGLImageSupported && buffer_is_dmabuf(buffer)) {
        videoBuffer.reset(new GstDmaVideoBuffer(buffer, videoMeta));
    } else {
        // TODO: support other memory types, probably GL textures?
        // just map memory
        videoBuffer.reset(new GstVideoBuffer(buffer, videoMeta));
    }

    QSize size = QSize(videoMeta->width, videoMeta->height);
    QVideoFrame::PixelFormat format =
        gst_video_format_to_qvideoformat(videoMeta->format);

    videoFrame.reset(new QVideoFrame(
        static_cast<QAbstractVideoBuffer*>(videoBuffer.get()), size, format));

    if (!m_surface->isActive()) {
        m_format = QVideoSurfaceFormat(size, format);
        Q_ASSERT(m_surface->start(m_format) == true);
    }
    m_surface->present(*videoFrame);
    gst_sample_unref(sample);
}

В этой функции мы забираем последний доступный нам буфер из appsink, запрашиваем GstVideoMeta чтобы узнать информацию об оффсетах и страйдах для каждого плейна (что ж, для простоты примера, никакого fallback на случай, если по какой-то причине меты нет, не предусмотрено) и создаем QAbstractVideoBuffer с нужным типом хедла: EGLImage (GstDmaVideoBuffer) или None (GstVideoBuffer). Затем оборачиваем это в QVideoFrame и отправляем в очередь на отрисовку.

Сама реализация GstDmaVideoBuffer и GstVideoBuffer достаточно тривиальна:

#define GST_BUFFER_GET_DMAFD(buffer, plane)                                    
    (((plane) < gst_buffer_n_memory((buffer))) ?                               
         gst_dmabuf_memory_get_fd(gst_buffer_peek_memory((buffer), (plane))) : 
         gst_dmabuf_memory_get_fd(gst_buffer_peek_memory((buffer), 0)))

class GstDmaVideoBuffer : public QAbstractVideoBuffer
{
public:
    // This  should be called from renderer thread
    GstDmaVideoBuffer(GstBuffer* buffer, GstVideoMeta* videoMeta) :
        QAbstractVideoBuffer(HandleType::EGLImageHandle),
        buffer(gst_buffer_ref(buffer)), m_videoMeta(videoMeta)

    {
        static PFNEGLCREATEIMAGEKHRPROC eglCreateImageKHR =
            reinterpret_cast<PFNEGLCREATEIMAGEKHRPROC>(
                eglGetProcAddress("eglCreateImageKHR"));
        int idx = 0;
        EGLint attribs[MAX_ATTRIBUTES_COUNT];

        attribs[idx++] = EGL_WIDTH;
        attribs[idx++] = m_videoMeta->width;
        attribs[idx++] = EGL_HEIGHT;
        attribs[idx++] = m_videoMeta->height;
        attribs[idx++] = EGL_LINUX_DRM_FOURCC_EXT;
        attribs[idx++] = gst_video_format_to_drm_code(m_videoMeta->format);
        attribs[idx++] = EGL_DMA_BUF_PLANE0_FD_EXT;
        attribs[idx++] = GST_BUFFER_GET_DMAFD(buffer, 0);
        attribs[idx++] = EGL_DMA_BUF_PLANE0_OFFSET_EXT;
        attribs[idx++] = m_videoMeta->offset[0];
        attribs[idx++] = EGL_DMA_BUF_PLANE0_PITCH_EXT;
        attribs[idx++] = m_videoMeta->stride[0];
        if (m_videoMeta->n_planes > 1) {
            attribs[idx++] = EGL_DMA_BUF_PLANE1_FD_EXT;
            attribs[idx++] = GST_BUFFER_GET_DMAFD(buffer, 1);
            attribs[idx++] = EGL_DMA_BUF_PLANE1_OFFSET_EXT;
            attribs[idx++] = m_videoMeta->offset[1];
            attribs[idx++] = EGL_DMA_BUF_PLANE1_PITCH_EXT;
            attribs[idx++] = m_videoMeta->stride[1];
        }
        if (m_videoMeta->n_planes > 2) {
            attribs[idx++] = EGL_DMA_BUF_PLANE2_FD_EXT;
            attribs[idx++] = GST_BUFFER_GET_DMAFD(buffer, 2);
            attribs[idx++] = EGL_DMA_BUF_PLANE2_OFFSET_EXT;
            attribs[idx++] = m_videoMeta->offset[2];
            attribs[idx++] = EGL_DMA_BUF_PLANE2_PITCH_EXT;
            attribs[idx++] = m_videoMeta->stride[2];
        }
        attribs[idx++] = EGL_NONE;

        auto m_qOpenGLContext = QOpenGLContext::currentContext();
        QEGLNativeContext qEglContext =
            qvariant_cast<QEGLNativeContext>(m_qOpenGLContext->nativeHandle());

        EGLDisplay dpy = qEglContext.display();
        Q_ASSERT(dpy != EGL_NO_DISPLAY);

        image = eglCreateImageKHR(dpy, EGL_NO_CONTEXT, EGL_LINUX_DMA_BUF_EXT,
                                  (EGLClientBuffer) nullptr, attribs);
        Q_ASSERT(image != EGL_NO_IMAGE_KHR);
    }

...

    // This should be called from renderer thread
    ~GstDmaVideoBuffer() override
    {
        static PFNEGLDESTROYIMAGEKHRPROC eglDestroyImageKHR =
            reinterpret_cast<PFNEGLDESTROYIMAGEKHRPROC>(
                eglGetProcAddress("eglDestroyImageKHR"));

        auto m_qOpenGLContext = QOpenGLContext::currentContext();
        QEGLNativeContext qEglContext =
            qvariant_cast<QEGLNativeContext>(m_qOpenGLContext->nativeHandle());

        EGLDisplay dpy = qEglContext.display();
        Q_ASSERT(dpy != EGL_NO_DISPLAY);
        eglDestroyImageKHR(dpy, image);
        gst_buffer_unref(buffer);
    }

private:
    EGLImage image;
    GstBuffer* buffer;
    GstVideoMeta* m_videoMeta;
};

class GstVideoBuffer : public QAbstractPlanarVideoBuffer
{
public:
    GstVideoBuffer(GstBuffer* buffer, GstVideoMeta* videoMeta) :
        QAbstractPlanarVideoBuffer(HandleType::NoHandle),
        m_buffer(gst_buffer_ref(buffer)), m_videoMeta(videoMeta),
        m_mode(QAbstractVideoBuffer::MapMode::NotMapped)
    {
    }

    QVariant handle() const override
    {
        return QVariant();
    }

    void release() override
    {
    }

    int map(MapMode mode,
            int* numBytes,
            int bytesPerLine[4],
            uchar* data[4]) override
    {
        int size = 0;
        const GstMapFlags flags =
            GstMapFlags(((mode & ReadOnly) ? GST_MAP_READ : 0) |
                        ((mode & WriteOnly) ? GST_MAP_WRITE : 0));
        if (mode == NotMapped || m_mode != NotMapped) {
            return 0;
        } else {
            for (int i = 0; i < m_videoMeta->n_planes; i++) {
                gst_video_meta_map(m_videoMeta, i, &m_mapInfo[i],
                                   (gpointer*)&data[i], &bytesPerLine[i],
                                   flags);
                size += m_mapInfo[i].size;
            }
        }
        m_mode = mode;
        *numBytes = size;
        return m_videoMeta->n_planes;
    }

    MapMode mapMode() const override
    {
        return m_mode;
    }

    void unmap() override
    {
        if (m_mode != NotMapped) {
            for (int i = 0; i < m_videoMeta->n_planes; i++) {
                gst_video_meta_unmap(m_videoMeta, i, &m_mapInfo[i]);
            }
        }
        m_mode = NotMapped;
    }

    ~GstVideoBuffer() override
    {
        unmap();
        gst_buffer_unref(m_buffer);
    }

private:
    GstBuffer* m_buffer;
    MapMode m_mode;
    GstVideoMeta* m_videoMeta;
    GstMapInfo m_mapInfo[4];
};

После всего этого мы можем собрать QML страничку следующего вида:

import QtQuick 2.10
import QtQuick.Window 2.10
import QtQuick.Layouts 1.10
import QtQuick.Controls 2.0
import QtMultimedia 5.10
import v4l2source 1.0

Window {
    visible: true
    width: 640
    height: 480
    title: qsTr("qml zero copy rendering")
    color: "black"

    CameraSource {
        id: camera
        device: "/dev/video0"
        onFrameReady: videoOutput.update()
    }

    VideoOutput {
        id: videoOutput
        source: camera
        anchors.fill: parent
    }

    onClosing: camera.stop()
}

Выводы

Целью данной статьи было показать, как интегрировать существующий API, который способен выдавать аппаратно-ускоренное видео, с QML и использовать существующие компоненты для отрисовки без копирования (ну или в худшем случае с одним, но без дорогостоящего софтверного конвертирования в RGB).

Ссылка на код

Ссылки

Автор: Rambden

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js