Как преобразовать YUV в RGB с помощью Media Foundation

· · Media Foundation, C++, Разработка Windows, Обработка видео, YUV

Извлечь кадр из видео и сохранить его как PNG, передать в WIC или GDI, либо вывести в UI. В таких сценариях приложению нужен массив пикселей в формате RGB.

Однако кадры, которые выдаёт decoder Media Foundation, чаще всего представлены в YUV-форматах вроде NV12 или YUY2. Если трактовать этот сырой поток байтов напрямую как изображение, получится немного грустная картинка: цвета ломаются, появляются полосы, изображение приобретает странный зеленоватый оттенок.

В более ранней статье «Что такое Media Foundation - почему за COM и медиа-API Windows начинает проступать одно и то же лицо» мы разобрали общую картину, а в статье «Как извлечь статичное изображение из MP4 в заданный момент времени с помощью Media Foundation - однофайловая версия, которую можно вставить прямо в .cpp» — извлечение статичных кадров. В этот раз мы разберём то, что находится между этими темами: само преобразование YUV -> RGB.

В этой статье мы отдельно разберём следующие два паттерна.

  • Паттерн A: доверить IMFSourceReader довести кадры до RGB32 автоматически
  • Паттерн B: получать NV12 / YUY2 и конвертировать в RGB самостоятельно

Цель — не запомнить названия API. Цель — суметь мысленно представить, в каком месте Media Foundation появляется YUV и где он превращается в RGB.

Код, который встречается в этой статье, опубликован на GitHub в виде полного набора примеров (C++ код для паттернов A и B, конфигурация CMake, тесты преобразования пикселей).

media-foundation-yuv-to-rgb-conversion-patterns - komurasoft-blog-samples (GitHub)

1. Сначала — вывод

Сначала — только выводы.

  • Для извлечения нескольких статичных кадров или генерации миниатюр проще всего включить MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING и запросить MFVideoFormat_RGB32
  • Однако это автоматическое преобразование выполняется программно (software) и не оптимизировано для воспроизведения в реальном времени
  • Если вы пишете собственное преобразование, кратчайший путь — сначала как следует разобраться в NV12 и YUY2
  • YUV -> RGB — это не «умножить на три коэффициента и готово»: на практике здесь задействованы субдискретизация, range, matrix и stride
  • В документации Media Foundation широко используется термин YUV, но применительно к цифровому video проще воспринимать его как фактическое обозначение Y’CbCr
  • На практике цвет чаще всего портят из-за того, что не смотрят на MF_MT_YUV_MATRIX и MF_MT_VIDEO_NOMINAL_RANGE, а также из-за предположения, что stride равен width * bytesPerPixel

Итого: если хочется сделать всё проще — пусть Source Reader сам выдаёт RGB32. Если нужна массовая обработка или контроль над цветом — принимайте кадры в YUV и конвертируйте сами. Выбор из этих двух вариантов.

2. Сначала — посмотрим на схему

Сначала быстрее будет посмотреть на схему того, что происходит внутри Media Foundation.

Паттерн AПаттерн BMP4 / H.264 / HEVCдекодерYUV-кадры: NV12 / YUY2 / YV12 и т.д.video processing в Source ReaderRGB32Собственный код преобразованияBGRA / RGB

Если содержимое видеофайла представлено в сжатом формате вроде H.264 или HEVC, decoder сначала возвращает его к несжатым кадрам. Эти несжатые кадры не обязательно оказываются RGB. Более того, в видеоподсистеме Windows YUV-форматы — это норма.

Поэтому, когда приложению нужен RGB, выбирают один из двух вариантов.

  1. Довести кадры до RGB32 силами самой Media Foundation
  2. Получить YUV и превратить его в RGB собственным кодом

Тема этой статьи — как раз эта развилка.

3. Сначала разберёмся с соотношением YUV и RGB

3.1. Хотя говорят YUV, на самом деле речь о Y’CbCr

Названия API и документация Windows широко используют термин YUV. Однако в контексте цифрового video можно почти без проблем читать U как Cb, а V как Cr.

Упрощённо соотношение таково:

  • Y — компонент, близкий к яркости
  • U / V — цветоразностные компоненты
  • RGB — каждый пиксель напрямую хранит значения Red / Green / Blue

Человеческий глаз чувствительнее к деталям яркости, чем к деталям цвета. Поэтому в video выгодно хранить Y подробно, а U/V — несколько более грубо. Именно поэтому YUV-форматы получили такое широкое распространение.

3.2. 4:4:4 / 4:2:2 / 4:2:0 — это «насколько прорежен цвет»

Это ключевой момент для понимания YUV.

Обозначение Значение Типичные примеры
4:4:4 Каждый pixel хранит собственные Y/U/V AYUV, I444
4:2:2 2 pixel по горизонтали используют общую пару U/V YUY2, UYVY, I422
4:2:0 Блок 2x2 pixel использует общую пару U/V NV12, YV12, I420

Стоит сразу посмотреть на структуру двух форматов, которые чаще всего встречаются на практике — это заметно облегчает понимание.

NV12 (4:2:0, planar)

Y plane
Y Y Y Y
Y Y Y Y
Y Y Y Y
Y Y Y Y

UV plane
U V U V
U V U V

В NV12 4 пикселя блока 2x2 используют общую пару U/V. Y присутствует для каждого пикселя отдельно.

YUY2 (4:2:2, packed)

bytes:
Y0 U0 Y1 V0   Y2 U2 Y3 V2   ...

В YUY2 2 пикселя по горизонтали используют общую пару U/V. Y0 и Y1 — разные значения, а U0 и V0 общие.

Уже на этом этапе видно, что YUV -> RGB — не простая замена «один пиксель на один пиксель». Сначала нужно продумать, как распределить общую пару U/V между пикселями.

3.3. YUV -> RGB — это «преобразование цветового пространства + преобразование сэмплирования»

Если посмотреть на Extended Color Information в Media Foundation, видно, что строго правильное цветовое преобразование состоит из немалого числа этапов: inverse quantization, chroma upsampling, YUV -> RGB, transfer function, преобразование primaries и, наконец, quantization.

Но если рассматривать это как практический код для 8-bit SDR, для начала проще разбить процесс на следующие три уровня.

  1. Отменить субдискретизацию. Развернуть U/V из 4:2:0 или 4:2:2 так, чтобы на них мог сослаться каждый pixel
  2. Отменить range. Y в video обычно использует диапазон 16..235, а U/V — 16..240, поэтому нужно отменить это масштабирование
  3. Применить matrix. Преобразовать в RGB с коэффициентами вроде BT.601 или BT.709

То есть на практике преобразование YUV -> RGB — это процесс, в котором решается:

  • какая пара U/V соответствует цвету данного pixel
  • с какими коэффициентами превратить это Y/U/V обратно в RGB

3.4. Если небрежно обращаться с BT.601 и BT.709, цвет постепенно уходит в сторону

В документации Media Foundation описывается соотношение, при котором BT.601 предпочтителен для SDTV и ниже, а BT.709 — для video выше SD.

Однако молча предполагать «раз разрешение большое, значит это 709» — не лучшая идея. Сдвиг цвета не приводит к сбою, поэтому легко остаётся незамеченным и попадает в продакшен.

В Media Foundation информация о цветовом пространстве может храниться в атрибутах media type. Как минимум стоит проверить эти два:

  • MF_MT_YUV_MATRIX
  • MF_MT_VIDEO_NOMINAL_RANGE

Если проверять эти два атрибута и явно пропускать только те комбинации, которые поддерживает ваш код, риск тихих проблем в будущем заметно снижается.

3.5. Первая формула, которую стоит запомнить — limited range версия BT.601

Типичная формула для 8-bit BT.601 выглядит так.

C = Y - 16
D = U - 128
E = V - 128

R = clip(1.164383 * C + 1.596027 * E)
G = clip(1.164383 * C - 0.391762 * D - 0.812968 * E)
B = clip(1.164383 * C + 2.017232 * D)

Для BT.709 коэффициенты меняются — мы приведём их и в коде позже.

Здесь важнее не «зазубрить коэффициенты», а понять структуру: из Y вычитается чёрный уровень 16, а U/V рассматриваются относительно центра 128.

4. Паттерн A: доверить преобразование Media Foundation

4.1. Когда этот способ подходит

Этот способ подходит, например, в следующих ситуациях:

  • нужно извлечь один статичный кадр из MP4
  • нужно создать несколько миниатюр
  • нужно получить RGB-изображение и передать его в WIC
  • подходит пакетная обработка или инструмент, а не воспроизведение в реальном времени

У Source Reader есть функция, которая с помощью MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING выполняет ограниченную video processing для YUV -> RGB32.

Однако, как указано и в Microsoft Learn, это программная (software) обработка, не оптимизированная для playback. Если нужно обрабатывать сотни кадров в секунду, опираться на этот механизм не совсем правильно.

4.2. Что нужно настроить, чтобы получить RGB32

Последовательность довольно прямолинейна.

  1. В attributes, передаваемых в MFCreateSourceReaderFromURL, установить MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING = TRUE
  2. Выбрать видео stream
  3. Запросить MFMediaType_Video / MFVideoFormat_RGB32 через SetCurrentMediaType
  4. Прочитать sample через ReadSample

Это всё — и ограниченная video processing, встроенная сразу после decoder, сама выполнит преобразование YUV -> RGB32.

4.3. Код

Приведённый ниже код предполагает, что CoInitializeEx и MFStartup уже выполнены. В минимальной конфигурации это выглядит примерно так.

#include <windows.h>
#include <mfapi.h>
#include <mfidl.h>
#include <mfreadwrite.h>
#include <mferror.h>
#include <wrl/client.h>

#pragma comment(lib, "mfplat.lib")
#pragma comment(lib, "mfreadwrite.lib")
#pragma comment(lib, "mfuuid.lib")
#pragma comment(lib, "ole32.lib")

using Microsoft::WRL::ComPtr;

HRESULT CreateSourceReaderWithAutoRgb(
    const wchar_t* path,
    IMFSourceReader** ppReader)
{
    if (!path || !ppReader) return E_POINTER;
    *ppReader = nullptr;

    ComPtr<IMFAttributes> attrs;
    HRESULT hr = MFCreateAttributes(&attrs, 2);
    if (FAILED(hr)) return hr;

    hr = attrs->SetUINT32(MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING, TRUE);
    if (FAILED(hr)) return hr;

    hr = MFCreateSourceReaderFromURL(path, attrs.Get(), ppReader);
    if (FAILED(hr)) return hr;

    hr = (*ppReader)->SetStreamSelection(MF_SOURCE_READER_ALL_STREAMS, FALSE);
    if (FAILED(hr)) return hr;

    hr = (*ppReader)->SetStreamSelection(MF_SOURCE_READER_FIRST_VIDEO_STREAM, TRUE);
    if (FAILED(hr)) return hr;

    ComPtr<IMFMediaType> outType;
    hr = MFCreateMediaType(&outType);
    if (FAILED(hr)) return hr;

    hr = outType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Video);
    if (FAILED(hr)) return hr;

    hr = outType->SetGUID(MF_MT_SUBTYPE, MFVideoFormat_RGB32);
    if (FAILED(hr)) return hr;

    hr = (*ppReader)->SetCurrentMediaType(
        MF_SOURCE_READER_FIRST_VIDEO_STREAM,
        nullptr,
        outType.Get());
    if (FAILED(hr)) return hr;

    return S_OK;
}

HRESULT ReadOneRgb32Sample(
    IMFSourceReader* reader,
    IMFSample** ppSample,
    LONGLONG* pTimestamp100ns)
{
    if (!reader || !ppSample) return E_POINTER;
    *ppSample = nullptr;
    if (pTimestamp100ns) *pTimestamp100ns = 0;

    DWORD streamIndex = 0;
    DWORD flags = 0;
    LONGLONG timestamp = 0;

    HRESULT hr = reader->ReadSample(
        MF_SOURCE_READER_FIRST_VIDEO_STREAM,
        0,
        &streamIndex,
        &flags,
        &timestamp,
        ppSample);

    if (FAILED(hr)) return hr;
    if (flags & MF_SOURCE_READERF_ENDOFSTREAM) return MF_E_END_OF_STREAM;
    if (*ppSample == nullptr) return MF_E_INVALID_STREAM_DATA;

    if (pTimestamp100ns) *pTimestamp100ns = timestamp;
    return S_OK;
}

После этого можно вызвать GetCurrentMediaType, чтобы узнать реальный размер вывода и stride.

4.4. Сильные стороны этого способа

Главное достоинство этого способа — он позволяет быстро получить корректную картинку.

  • не нужно самостоятельно писать раскрытие 4:2:0 / 4:2:2
  • значительная часть хлопот с matrix / deinterlace скрыта от вас
  • результат легко передать в WIC или GDI
  • вполне практичен для обработки нескольких кадров

Для инструментов извлечения статичных изображений совершенно естественно начинать именно отсюда.

4.5. Но есть и подводные камни

У этого автоматического преобразования есть следующие особенности.

Параметр Значение
Целевой формат Как правило RGB32
Реализация Программная (software) обработка
Подходящее применение небольшое число frame, миниатюры, offline-обработка
Неподходящее применение real-time rendering на базе D3D, обработка большого числа frame
Плохо совместимые атрибуты MF_SOURCE_READER_D3D_MANAGER, MF_READWRITE_DISABLE_CONVERTERS

И ещё один важный момент — обращение с 4-м байтом RGB32. В памяти Windows RGB32 хранится в порядке Blue / Green / Red / Alpha or Don’t Care. Это не ARGB32. Если вы передаёте данные в WIC как 32bppBGRA, безопаснее заполнить 4-й байт значением 0xFF, сделав его непрозрачным.

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

5. Паттерн B: написать преобразование самостоятельно

5.1. Когда этот способ подходит

Собственное преобразование подходит, например, в таких случаях:

  • нужно обрабатывать большое число frame, и вы хотите оптимизировать преобразование самостоятельно
  • нужно передавать NV12 напрямую в GPU или SIMD-код
  • нужно явно управлять BT.601 / BT.709 / range
  • нужен выходной формат, отличный от RGB32
  • ограниченного автоматического преобразования Source Reader недостаточно

Можно сказать, это паттерн, при котором вы берёте на себя ответственность за объём обработки и цвет в обмен на свободу.

5.2. Общий поток собственного преобразования

Последовательность такая.

  1. Настроить вывод Source Reader на NV12 или YUY2
  2. Получить реальный subtype и атрибуты через GetCurrentMediaType
  3. Проверить MF_MT_FRAME_SIZE, MF_MT_DEFAULT_STRIDE, MF_MT_YUV_MATRIX, MF_MT_VIDEO_NOMINAL_RANGE
  4. Извлечь buffer из sample и заблокировать его (lock)
  5. Определить, какие Y/U/V соответствуют каждому pixel
  6. Применить matrix и записать результат в BGRA

Код в этой статье ограничен случаем 8-bit SDR / progressive / NV12 или YUY2 / limited range. Сужение допущений здесь — не халтура, а, наоборот, важный шаг: если реализовать YUV-преобразование как «принимаем всё подряд», цвет легко портится незаметно.

5.3. Сначала явно задаём выходной media type

Сначала сообщаем Source Reader: «выдавай YUV как есть». Здесь тоже предполагается, что CoInitializeEx / MFStartup уже выполнены.

#include <windows.h>
#include <mfapi.h>
#include <mfidl.h>
#include <mfreadwrite.h>
#include <mferror.h>
#include <wrl/client.h>

using Microsoft::WRL::ComPtr;

HRESULT ConfigureSourceReaderForSubtype(
    IMFSourceReader* reader,
    REFGUID subtype)
{
    if (!reader) return E_POINTER;

    HRESULT hr = reader->SetStreamSelection(MF_SOURCE_READER_ALL_STREAMS, FALSE);
    if (FAILED(hr)) return hr;

    hr = reader->SetStreamSelection(MF_SOURCE_READER_FIRST_VIDEO_STREAM, TRUE);
    if (FAILED(hr)) return hr;

    ComPtr<IMFMediaType> outType;
    hr = MFCreateMediaType(&outType);
    if (FAILED(hr)) return hr;

    hr = outType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Video);
    if (FAILED(hr)) return hr;

    hr = outType->SetGUID(MF_MT_SUBTYPE, subtype);
    if (FAILED(hr)) return hr;

    hr = reader->SetCurrentMediaType(
        MF_SOURCE_READER_FIRST_VIDEO_STREAM,
        nullptr,
        outType.Get());
    if (FAILED(hr)) return hr;

    return S_OK;
}

Сюда в качестве subtype передаётся MFVideoFormat_NV12 или MFVideoFormat_YUY2.

Стоит учитывать: запрошенный subtype не обязательно принимается как есть. То, что реально получилось на выходе, проверяется через GetCurrentMediaType.

5.4. Перед преобразованием принимаем только поддерживаемую информацию о цвете

При собственном преобразовании сначала извлекаем из media type минимально необходимую информацию. В примере этой статьи принимаются только NV12 / YUY2, а из matrix пропускаются только BT.601 или BT.709, из range — только MFNominalRange_16_235.

#include <vector>

struct DecodedFrameInfo
{
    GUID subtype = GUID_NULL;
    UINT32 width = 0;
    UINT32 height = 0;
    LONG defaultStride = 0;
    MFVideoTransferMatrix matrix = MFVideoTransferMatrix_Unknown;
    MFNominalRange nominalRange = MFNominalRange_Unknown;
};

HRESULT GetDefaultStride(
    IMFMediaType* pType,
    LONG* plStride)
{
    if (!pType || !plStride) return E_POINTER;

    LONG stride = 0;
    HRESULT hr = pType->GetUINT32(
        MF_MT_DEFAULT_STRIDE,
        reinterpret_cast<UINT32*>(&stride));

    if (FAILED(hr))
    {
        GUID subtype = GUID_NULL;
        UINT32 width = 0;
        UINT32 height = 0;

        hr = pType->GetGUID(MF_MT_SUBTYPE, &subtype);
        if (FAILED(hr)) return hr;

        hr = MFGetAttributeSize(pType, MF_MT_FRAME_SIZE, &width, &height);
        if (FAILED(hr)) return hr;

        hr = MFGetStrideForBitmapInfoHeader(subtype.Data1, width, &stride);
        if (FAILED(hr)) return hr;

        hr = pType->SetUINT32(MF_MT_DEFAULT_STRIDE, static_cast<UINT32>(stride));
        if (FAILED(hr)) return hr;
    }

    *plStride = stride;
    return S_OK;
}

HRESULT GetStrictDecodedFrameInfo(
    IMFMediaType* pType,
    DecodedFrameInfo* pInfo)
{
    if (!pType || !pInfo) return E_POINTER;

    HRESULT hr = pType->GetGUID(MF_MT_SUBTYPE, &pInfo->subtype);
    if (FAILED(hr)) return hr;

    if (pInfo->subtype != MFVideoFormat_NV12 &&
        pInfo->subtype != MFVideoFormat_YUY2)
    {
        return MF_E_INVALIDMEDIATYPE;
    }

    hr = MFGetAttributeSize(pType, MF_MT_FRAME_SIZE, &pInfo->width, &pInfo->height);
    if (FAILED(hr)) return hr;

    hr = GetDefaultStride(pType, &pInfo->defaultStride);
    if (FAILED(hr)) return hr;

    UINT32 value = 0;

    hr = pType->GetUINT32(MF_MT_YUV_MATRIX, &value);
    if (FAILED(hr)) return hr;

    pInfo->matrix = static_cast<MFVideoTransferMatrix>(value);
    if (pInfo->matrix != MFVideoTransferMatrix_BT601 &&
        pInfo->matrix != MFVideoTransferMatrix_BT709)
    {
        return MF_E_INVALIDMEDIATYPE;
    }

    hr = pType->GetUINT32(MF_MT_VIDEO_NOMINAL_RANGE, &value);
    if (FAILED(hr)) return hr;

    pInfo->nominalRange = static_cast<MFNominalRange>(value);
    if (pInfo->nominalRange != MFNominalRange_16_235)
    {
        return MF_E_INVALIDMEDIATYPE;
    }

    return S_OK;
}

Здесь мы намеренно используем strict-подход. В документации по enum Media Foundation встречаются формулировки вроде «Unknown трактуется как BT.709», но если молча округлять такие случаи на практике, сдвиг цвета становится труднее заметить. По крайней мере в первой реализации безопаснее считать ошибкой любую неподдерживаемую комбинацию.

Для камер и источников на базе JPEG иногда хочется отдельно обрабатывать пути с full-range. Здесь мы не смешиваем их молча, а придерживаемся политики явного сужения допущений, которые принимает этот код.

5.5. Читаем buffer, доверяя значению stride

Это тоже очень важный момент.

  • MF_MT_DEFAULT_STRIDE — это минимальный stride
  • у реального sample buffer может быть actual stride с учётом padding
  • если доступен IMF2DBuffer::Lock2D, отдавайте предпочтение ему

Если взять паттерн-helper из Uncompressed Video Buffers на Microsoft Learn и сделать его сразу пригодным для использования, получится следующее.

class BufferLock
{
public:
    explicit BufferLock(IMFMediaBuffer* buffer)
        : m_buffer(buffer),
          m_2dBuffer(nullptr),
          m_locked(false)
    {
        if (m_buffer)
        {
            m_buffer->AddRef();
            m_buffer->QueryInterface(IID_PPV_ARGS(&m_2dBuffer));
        }
    }

    ~BufferLock()
    {
        Unlock();

        if (m_2dBuffer)
        {
            m_2dBuffer->Release();
            m_2dBuffer = nullptr;
        }

        if (m_buffer)
        {
            m_buffer->Release();
            m_buffer = nullptr;
        }
    }

    HRESULT Lock(
        LONG defaultStride,
        DWORD heightInPixels,
        BYTE** ppScanline0,
        LONG* pActualStride)
    {
        if (!m_buffer || !ppScanline0 || !pActualStride) return E_POINTER;
        if (m_locked) return MF_E_INVALIDREQUEST;

        if (m_2dBuffer)
        {
            HRESULT hr = m_2dBuffer->Lock2D(ppScanline0, pActualStride);
            if (FAILED(hr)) return hr;

            m_locked = true;
            return S_OK;
        }

        BYTE* pData = nullptr;
        HRESULT hr = m_buffer->Lock(&pData, nullptr, nullptr);
        if (FAILED(hr)) return hr;

        *pActualStride = defaultStride;
        if (defaultStride < 0)
        {
            *ppScanline0 =
                pData + static_cast<size_t>(-defaultStride) * (heightInPixels - 1);
        }
        else
        {
            *ppScanline0 = pData;
        }

        m_locked = true;
        return S_OK;
    }

    void Unlock()
    {
        if (!m_locked) return;

        if (m_2dBuffer)
        {
            m_2dBuffer->Unlock2D();
        }
        else
        {
            m_buffer->Unlock();
        }

        m_locked = false;
    }

private:
    IMFMediaBuffer* m_buffer;
    IMF2DBuffer* m_2dBuffer;
    bool m_locked;
};

Рекомендуемое определение YUV surface подразумевает top-left / положительный stride, но для реального доступа к buffer безопаснее использовать pitch, возвращённый API, как есть. Если здесь жёстко закладываться на width, позже всё тихо сломается.

5.6. Переводим формулу преобразования одного pixel в код

Здесь рассматриваются только limited-range варианты BT.601 и BT.709. Выходной формат — BGRA32, который удобно передавать в WIC или GDI.

inline BYTE ClampToByte(double value)
{
    if (value <= 0.0) return 0;
    if (value >= 255.0) return 255;
    return static_cast<BYTE>(value + 0.5);
}

HRESULT ConvertLimitedYuvPixelToBgra(
    BYTE y,
    BYTE u,
    BYTE v,
    MFVideoTransferMatrix matrix,
    BYTE* dstPixel)
{
    if (!dstPixel) return E_POINTER;

    const double c = static_cast<double>(y) - 16.0;
    const double d = static_cast<double>(u) - 128.0;
    const double e = static_cast<double>(v) - 128.0;

    double r = 0.0;
    double g = 0.0;
    double b = 0.0;

    switch (matrix)
    {
    case MFVideoTransferMatrix_BT601:
        r = 1.164383 * c + 1.596027 * e;
        g = 1.164383 * c - 0.391762 * d - 0.812968 * e;
        b = 1.164383 * c + 2.017232 * d;
        break;

    case MFVideoTransferMatrix_BT709:
        r = 1.164383 * c + 1.792741 * e;
        g = 1.164383 * c - 0.213249 * d - 0.532909 * e;
        b = 1.164383 * c + 2.112402 * d;
        break;

    default:
        return MF_E_INVALIDMEDIATYPE;
    }

    dstPixel[0] = ClampToByte(b);
    dstPixel[1] = ClampToByte(g);
    dstPixel[2] = ClampToByte(r);
    dstPixel[3] = 255;

    return S_OK;
}

Здесь выполняется простая последовательность действий:

  • из Y вычитается 16
  • из U / V вычитается 128
  • применяются коэффициенты соответствующего matrix
  • результат ограничивается (clip) диапазоном 0..255
  • 4-й байт BGRA устанавливается в 255

5.7. Преобразование NV12 в BGRA32

NV12 — формат 4:2:0, поэтому 4 пикселя блока 2x2 используют одну и ту же пару U/V. В минимальной реализации проще всего использовать эту общую chroma напрямую для всех 4 пикселей.

HRESULT ConvertNv12ToBgra32(
    IMFMediaBuffer* buffer,
    const DecodedFrameInfo& info,
    std::vector<BYTE>& dstBgra)
{
    if (!buffer) return E_POINTER;
    if (info.subtype != MFVideoFormat_NV12) return MF_E_INVALIDMEDIATYPE;
    if ((info.width & 1u) != 0 || (info.height & 1u) != 0)
    {
        return MF_E_INVALIDMEDIATYPE;
    }

    dstBgra.resize(static_cast<size_t>(info.width) * info.height * 4);

    BufferLock lock(buffer);

    BYTE* scanline0 = nullptr;
    LONG actualStride = 0;
    HRESULT hr = lock.Lock(
        info.defaultStride,
        info.height,
        &scanline0,
        &actualStride);
    if (FAILED(hr)) return hr;

    if (actualStride <= 0)
    {
        lock.Unlock();
        return MF_E_INVALIDMEDIATYPE;
    }

    const BYTE* yPlane = scanline0;
    const BYTE* uvPlane =
        scanline0 + static_cast<size_t>(actualStride) * info.height;

    for (UINT32 y = 0; y < info.height; ++y)
    {
        const BYTE* yRow = yPlane + static_cast<size_t>(actualStride) * y;
        const BYTE* uvRow = uvPlane + static_cast<size_t>(actualStride) * (y / 2);
        BYTE* dstRow =
            dstBgra.data() + static_cast<size_t>(info.width) * 4 * y;

        for (UINT32 x = 0; x < info.width; ++x)
        {
            const BYTE Y = yRow[x];
            const BYTE U = uvRow[(x / 2) * 2 + 0];
            const BYTE V = uvRow[(x / 2) * 2 + 1];

            hr = ConvertLimitedYuvPixelToBgra(
                Y,
                U,
                V,
                info.matrix,
                dstRow + static_cast<size_t>(x) * 4);
            if (FAILED(hr))
            {
                lock.Unlock();
                return hr;
            }
        }
    }

    lock.Unlock();
    return S_OK;
}

Этот код трактует chroma upsampling в духе nearest-neighbor. Визуально этого часто вполне достаточно, но если стремиться к максимальному качеству, теоретически более аккуратным будет решение, при котором сначала выполняется upconversion 4:2:0 -> 4:2:2 -> 4:4:4, как описано в статье Microsoft Learn о YUV.

5.8. Преобразование YUY2 в BGRA32

YUY2 — это packed-формат 4:2:2. Пара U/V используется всего двумя пикселями, поэтому читать такой код немного проще, чем для NV12.

#include <cstddef>

HRESULT ConvertYuy2ToBgra32(
    IMFMediaBuffer* buffer,
    const DecodedFrameInfo& info,
    std::vector<BYTE>& dstBgra)
{
    if (!buffer) return E_POINTER;
    if (info.subtype != MFVideoFormat_YUY2) return MF_E_INVALIDMEDIATYPE;
    if ((info.width & 1u) != 0) return MF_E_INVALIDMEDIATYPE;

    dstBgra.resize(static_cast<size_t>(info.width) * info.height * 4);

    BufferLock lock(buffer);

    BYTE* scanline0 = nullptr;
    LONG actualStride = 0;
    HRESULT hr = lock.Lock(
        info.defaultStride,
        info.height,
        &scanline0,
        &actualStride);
    if (FAILED(hr)) return hr;

    for (UINT32 y = 0; y < info.height; ++y)
    {
        const BYTE* src =
            scanline0 +
            static_cast<ptrdiff_t>(actualStride) * static_cast<ptrdiff_t>(y);

        BYTE* dstRow =
            dstBgra.data() + static_cast<size_t>(info.width) * 4 * y;

        for (UINT32 x = 0; x < info.width; x += 2)
        {
            const BYTE Y0 = src[0];
            const BYTE U  = src[1];
            const BYTE Y1 = src[2];
            const BYTE V  = src[3];

            hr = ConvertLimitedYuvPixelToBgra(
                Y0,
                U,
                V,
                info.matrix,
                dstRow + static_cast<size_t>(x) * 4);
            if (FAILED(hr))
            {
                lock.Unlock();
                return hr;
            }

            hr = ConvertLimitedYuvPixelToBgra(
                Y1,
                U,
                V,
                info.matrix,
                dstRow + static_cast<size_t>(x + 1) * 4);
            if (FAILED(hr))
            {
                lock.Unlock();
                return hr;
            }

            src += 4;
        }
    }

    lock.Unlock();
    return S_OK;
}

Байты YUY2 идут в порядке Y0 U Y1 V, поэтому структура «переиспользуем U/V на каждые 2 пикселя» видна напрямую. Благодаря этому мысленную модель для YUY2 строить проще, чем для NV12.

5.9. Точка входа при вызове из sample

Наконец, если извлечь непрерывный buffer из IMFSample и разветвиться по subtype, использовать код становится удобно.

HRESULT ConvertSampleToBgra32(
    IMFSample* sample,
    const DecodedFrameInfo& info,
    std::vector<BYTE>& dstBgra)
{
    if (!sample) return E_POINTER;

    ComPtr<IMFMediaBuffer> buffer;
    HRESULT hr = sample->ConvertToContiguousBuffer(&buffer);
    if (FAILED(hr)) return hr;

    if (info.subtype == MFVideoFormat_NV12)
    {
        return ConvertNv12ToBgra32(buffer.Get(), info, dstBgra);
    }

    if (info.subtype == MFVideoFormat_YUY2)
    {
        return ConvertYuy2ToBgra32(buffer.Get(), info, dstBgra);
    }

    return MF_E_INVALIDMEDIATYPE;
}

Теперь предшествующие шаги выстраиваются в такую последовательность:

  • создать reader
  • запросить NV12 или YUY2
  • сформировать DecodedFrameInfo из GetCurrentMediaType
  • ReadSample
  • ConvertSampleToBgra32

Код на стороне вызывающей стороны выглядит, например, так.

ComPtr<IMFMediaType> currentType;
HRESULT hr = reader->GetCurrentMediaType(
    MF_SOURCE_READER_FIRST_VIDEO_STREAM,
    &currentType);
if (FAILED(hr)) return hr;

DecodedFrameInfo info;
hr = GetStrictDecodedFrameInfo(currentType.Get(), &info);
if (FAILED(hr)) return hr;

DWORD flags = 0;
LONGLONG timestamp = 0;
ComPtr<IMFSample> sample;

hr = reader->ReadSample(
    MF_SOURCE_READER_FIRST_VIDEO_STREAM,
    0,
    nullptr,
    &flags,
    &timestamp,
    &sample);
if (FAILED(hr)) return hr;
if (flags & MF_SOURCE_READERF_ENDOFSTREAM) return MF_E_END_OF_STREAM;
if (!sample) return MF_E_INVALID_STREAM_DATA;

std::vector<BYTE> bgra;
hr = ConvertSampleToBgra32(sample.Get(), info, bgra);
if (FAILED(hr)) return hr;

// bgra можно рассматривать как top-down / 32bpp BGRA

5.10. Где размещать «собственное преобразование»

Код, рассмотренный выше, представляет собой форму, при которой преобразование выполняет само приложение после Source Reader. Это самый понятный вариант.

Однако если хочется встроить преобразование прямо в pipeline Media Foundation, существуют и другие подходы.

  • написать собственный MFT
  • использовать Video Processor MFT / XVP
  • написать shader NV12 -> RGB на стороне GPU

Если зайти настолько далеко, тема несколько меняется, поэтому в этой статье мы ограничились кодом на стороне приложения. Но полезно знать, что между «доверить всё Media Foundation» и «сделать всё в приложении» существует промежуточная точка — Video Processor MFT.

6. Какой вариант выбрать

Если сомневаетесь, следующая таблица хорошо помогает разобраться.

Критерий Автоматическое преобразование (MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING) Собственное преобразование
Скорость реализации
Извлечение нескольких статичных кадров
Большое число frame / real-time
Явный контроль matrix / range
Совмещение с GPU / D3D ○〜◎
Нужен вывод, отличный от RGB32
Понимание принципов

Для первой реализации проще рассуждать так:

  • хочется сначала просто запустить -> автоматическое преобразование
  • хочется взять на себя ответственность за цвет и производительность -> собственное преобразование

На практике вполне эффективен и такой порядок: «сначала убедиться в корректности картинки с помощью автоматического преобразования, а затем заменить его на manual path». Если с самого начала брать на себя всё сразу, становится трудно понять, в каком месте картинка сломалась.

7. Подводные камни, в которые легко попасть на практике

7.1. Считать RGB32 форматом RGBA с alpha

В памяти RGB32 хранится как B, G, R, Alpha or Don't Care. Если сохранить это напрямую как BGRA в PNG, 4-й байт может оказаться равным 0, и изображение станет прозрачным. Безопаснее перед сохранением записать в этот байт 0xFF.

7.2. Жёстко задавать stride через width * bytesPerPixel

Это довольно распространённая ошибка. В реальном sample buffer может присутствовать padding, поэтому правило таково: для перехода между row нужно использовать actual stride.

7.3. Путать MF_MT_DEFAULT_STRIDE с actual pitch

MF_MT_DEFAULT_STRIDE — это «минимальный stride при представлении данного format в непрерывной памяти». Для actual pitch у sample buffer стоит отдавать приоритет значению, которое возвращает IMF2DBuffer::Lock2D.

7.4. Молча угадывать 601 / 709, не глядя на color metadata

Проблемы с цветом трудно заметить визуально. Они также не приводят к сбою. Именно поэтому они и коварны.

  • MF_MT_YUV_MATRIX
  • MF_MT_VIDEO_NOMINAL_RANGE

Проверяйте хотя бы эти два атрибута. И правильный настрой — считать ошибкой любое значение, которое не поддерживает ваш код.

7.5. Вычислять UV plane в NV12 через width * height

Смещение plane определяется реальными stride и height, а не width * height. Если сделать это небрежно, цвет сместится или изображение будет повреждено.

7.6. Обрабатывать interlaced video как progressive

Manual-пример в этой статье рассчитан на progressive. Если читать interlaced-видео как один field напрямую, может появиться гребенчатый шум. Если нужен deinterlace, разумнее рассмотреть автоматическую video processing в Source Reader или Video Processor MFT.

7.7. Игнорировать качество chroma upsampling для 4:2:0

Преобразование NV12 в этой статье ради простоты использует общую chroma напрямую для каждого пикселя. Для некоторых сценариев этого достаточно, но если приоритет — качество изображения, стоит разобраться и с подходом upconversion, описанным в документации по рекомендованным YUV-форматам.

8. Итог

При преобразовании YUV в RGB в Media Foundation стоит держать в голове следующую схему — тогда заблудиться будет намного сложнее.

  • сразу после decoder обычно появляется не RGB, а NV12 или YUY2
  • если хочется проще — запросите RGB32 через MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING
  • если хочется контроля — получайте NV12 / YUY2 и конвертируйте в BGRA сами
  • на manual path важнее формулы разобраться с sampling / range / matrix / stride
  • неопределённость с BT.601 / BT.709, 16..235, 4:2:0 / 4:2:2 приводит к сдвигу цвета или испорченной картинке

YUV -> RGB поначалу выглядит немного пугающе. Но стоит один раз уложить в голове картину:

  • NV12 — U/V общие на блок 2x2
  • YUY2 — U/V общие на 2 пикселя по горизонтали
  • к этим U/V и Y применяется matrix

и всё становится вполне понятным. Загадочная последовательность байт «космического» цвета начинает выглядеть как вполне осмысленные пиксели.

9. Справочные материалы

Код примеров к этой статье

Статьи по теме от KomuraSoft

Microsoft Learn

Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.

Эти страницы показывают тему статьи в более широком контексте услуг и решений.

Статья напрямую связана со следующими услугами.

Технические консультации и ревью дизайна

Если нужно заранее продумать распределение ответственности между YUV/RGB-преобразованием, цветовое пространство, stride и маршрут преобразования, эта тема хорошо подходит для технической консультации и ревью архитектуры.

Частые вопросы

Вопросы, которые часто возникают при консультациях по теме статьи.

Почему декодер Media Foundation выдаёт YUV, а не RGB?
Человеческий глаз чувствительнее к деталям яркости, чем к деталям цвета, поэтому в video выгодно хранить Y (компонент, близкий к яркости) подробно, а U/V (цветоразностные компоненты) — более грубо. Именно поэтому в видеоподсистеме Windows несжатые кадры, которые отдаёт decoder, обычно представлены в YUV-форматах вроде NV12 или YUY2. Отметим, что в контексте цифрового video термин YUV фактически означает Y'CbCr — так проще воспринимать материал.
Как проще всего получить кадры в RGB?
Самый простой способ — включить MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING в IMFSourceReader и запросить MFVideoFormat_RGB32. Для извлечения нескольких статичных кадров или генерации миниатюр это самый лёгкий путь. Однако это автоматическое преобразование выполняется программно (software) и не оптимизировано для воспроизведения в реальном времени, поэтому для массовой обработки или когда нужен контроль над цветом лучше получать кадры в YUV и конвертировать их самостоятельно.
На что обратить внимание при самостоятельном преобразовании YUV в RGB?
Дело не сводится к умножению на три коэффициента — в игру вступают субдискретизация (4:2:0 / 4:2:2), range, matrix и stride. На практике цвет чаще всего портят два момента: игнорирование MF_MT_YUV_MATRIX и MF_MT_VIDEO_NOMINAL_RANGE, а также предположение, что stride равен width × bytesPerPixel. Кратчайший путь — сначала как следует разобраться в структуре NV12 и YUY2.
В чём разница между NV12 и YUY2?
NV12 — это формат 4:2:0: за Y plane следует UV plane, в котором U и V чередуются, а 4 пикселя блока 2x2 используют общую пару U/V. YUY2 — формат 4:2:2, где пару U/V используют 2 пикселя по горизонтали. Оба формата часто встречаются на практике и различаются способом прореживания цвета (субдискретизацией).

Об авторе

Страница с профилем автора статьи.

Го Комура

Представитель KomuraSoft LLC

Специализируется на разработке программного обеспечения для Windows, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.

Публичные ссылки

Вернуться в блог