Qt6 QML 中渲染自定义视频帧的改进 2023-05-30 更新

2023-10-21 15:40:14 浏览数 (3)

最近在升级音视频的项目 Qt 版本,从 5.15.0 升级到 6.4.3(6.5 也一样),除了一些 QML 中删除了一些 Qt Quick Controls 1 的控件以外,最重要的就是自定义视频渲染的改进。

QAbstractVideoSurface 变为 QVideoSink

Qt5 中在 QML 上渲染自定义视频帧时需要在 C 层实现一个派生于 QObject 的子类,内部使用 QAbstractVideoSurface 来给 VideoOutput 提供数据,具体方法这里就不讨论了,可以参考我之前写的文章 Qt QML VideoOutput 显示自定义的 YUV420P 数据流 在 Qt6 中,QAbstractVideoSurface 被 QVideoSink 替代,提供了更简单的方式来投递一个 QVideoFrame,示例代码如下:

代码语言:javascript复制
class FrameProvider : public QObject {
    Q_OBJECT
    Q_PROPERTY(QVideoSink* videoSink READ videoSink WRITE setVideoSink NOTIFY videoSinkChanged)

public:
    explicit FrameProvider(QObject* parent = nullptr);
    ~FrameProvider();

    QVideoSink* videoSink() const { return m_videoSink; }
    void setVideoSink(QVideoSink* videoSink);

signals:
    void videoSinkChanged();

public slots:
    void deliverFrame(const QVideoFrame& frame);

private:
    QPointer<QVideoSink> m_videoSink;
};

类声明一个槽函数 deliverFrame 提供视频帧提供的模块绑定并投递帧数据。在 cpp 实现中只如果有新的视频流,则直接调用 m_videoSink 的 setVideoFrame 方法就可以了:

代码语言:javascript复制
void FrameProvider::deliverFrame(const QVideoFrame& frame) {
    if (!m_videoSink)
        return;
    m_videoSink->setVideoFrame(frame);
}

将 FrameProvider 按上面文章中的方法一样,注册给到 QML 端,与 VideoOutput 配合使用时也稍微有一些变动:

代码语言:javascript复制
FrameProvider {
    id: frameProvider
    videoSink: videoContainer.videoSink
}
VideoOutput {
    id: videoContainer
    anchors.fill: parent
    fillMode: VideoOutput.PreserveAspectFit
}

这样 VideoOutput 与新的 FrameProvider 配合使用就完成了,接下来我们说一下 QVideoFrame 的变动:

QVideoFrame 数据拷贝方式的变动

在 Qt5 中,如拷贝 YUV 数据到 QVideoFrame 的方式非常暴力,通过 videoFrame.bits() 拿到地址算好位置无脑拷贝就可以了:

代码语言:javascript复制
int frameSize = static_cast<int>(frame.width * frame.height * frame.count / 2);
QVideoFrame videoFrame(frameSize, QSize(static_cast<int>(rotationWidth), static_cast<int>(rotationHeight)), static_cast<int>(rotationWidth),
                       QVideoFrame::Format_YUV420P);

if (videoFrame.map(QAbstractVideoBuffer::WriteOnly)) {
    auto src = reinterpret_cast<uint8_t*>(frame.data);
    auto dest = reinterpret_cast<uint8_t*>(videoFrame.bits());

    libyuv::I420Rotate(src   frame.offset[0], static_cast<int>(frame.stride[0]),
                       src   frame.offset[1], static_cast<int>(frame.stride[1]),
                       src   frame.offset[2], static_cast<int>(frame.stride[2]),
                       dest, static_cast<int>(rotationWidth),
                       dest   rotationWidth * rotationHeight, rotationWidth / 2,
                       dest   rotationWidth * rotationHeight   rotationWidth * rotationHeight / 4, rotationWidth / 2,
                       static_cast<int>(frame.width), static_cast<int>(frame.height), rotate_mode);

    videoFrame.setStartTime(0);
    videoFrame.unmap();

    QSize size = QSize(static_cast<int>(rotationWidth), static_cast<int>(rotationHeight));
    emit VideoManager::m_videoFrameDelegate->receivedVideoFrame(QString::fromStdString(accountId), videoFrame, size, bSub);
}

但 Qt6 中出现了较大的变动,首先 bits 函数要求传递目标数据的 plane,比如 Y plane 为 0,U 和 V 依次为 1 和 2。这看起来跟 Qt5 中没有什么太大区别,但如果你按 bits(0)、bits(1)、bits(1) 的地址按原来的逻辑拷贝时会发现部分分辨率的图像会渲染错乱,这基本上是因为原始的 YUV 数据宽度并不是 16 的倍数。而 QVideoFrame 一旦调用了 map 函数,则每个 plane 的 stride(在 Qt 中称为 bytesPerLine) 将会是 16 的倍数,如果你按原始数据宽度拷贝,就会导致画面错乱。 正确的做法是通过 QVideoFrame 提供的 bytesPerLine() 方法算出具体每个 plane 的宽度,按需拷贝,实现如下:

代码语言:javascript复制
QVideoFrameFormat format(QSize(rotationWidth, rotationHeight), QVideoFrameFormat::Format_YUV420P);
format.setViewport(QRect(0, 0, rotationWidth, rotationHeight));
QVideoFrame videoFrame(format);
if (videoFrame.map(QVideoFrame::WriteOnly)) {
    auto src = reinterpret_cast<uint8_t*>(frame.data);
    // If the aspect ratio of the original data is not a multiple of 16,
    // when mappedBytes(n) is called after frame mapping, the returned size will be expanded to the nearest multiple of 16.
    // When copying the data, bytesPerLine(n) should be used to get the actual stride that needs to be copied.
    libyuv::I420Rotate(src   frame.offset[0], frame.stride[0],
                       src   frame.offset[1], frame.stride[1],
                       src   frame.offset[2], frame.stride[2],
                       videoFrame.bits(0), videoFrame.bytesPerLine(0),
                       videoFrame.bits(1), videoFrame.bytesPerLine(1),
                       videoFrame.bits(2), videoFrame.bytesPerLine(2),
                       frame.width, frame.height, rotate_mode);
    videoFrame.setStartTime(0);
    videoFrame.unmap();
    QSize size = QSize(static_cast<int>(rotationWidth), static_cast<int>(rotationHeight));
    emit VideoManager::m_videoFrameDelegate->receivedVideoFrame(QString::fromStdString(accountId), videoFrame, size, bSub);
}

其中 frame.data 是 YUV 的原始数据。通过改动后的 QVideoFrame API 我们可以看到,Qt 对视频处理数据的要求更加严谨了,虽然处理问题过程中浪费了比较多的时间,但总算总结下了一些宝贵的经验。

2023-05-30 更新

以上拷贝方式当使用 Qt 6.x 版本默认的渲染引擎(OpenGL)时一些奇葩的分辨率会出现花屏的问题。修改 Qt 的渲染引擎为各平台特有引擎后得以解决:

代码语言:javascript复制
int main(int argc, char* argv[]) {
    QGuiApplication app(argc, argv);

#if defined(Q_OS_MACX)
    QQuickWindow::setGraphicsApi(QSGRendererInterface::Metal);
#else
    QQuickWindow::setGraphicsApi(QSGRendererInterface::Direct3D11);
#endif
    ..... other code
}

1 人点赞