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

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

История изменений (1 обновлений, последнее 30 Aug 2026)

Журнал изменений этой статьи. Там, где версия до правки была заархивирована, она остаётся доступной для чтения по постоянной ссылке с DOI.

Русский текст переписан как полноценный технический перевод, а не калька с японского. Утверждения статьи не менялись.
Первая публикация
Цитирование статьи(DOI: 10.5281/zenodo.21619689)

Статья заархивирована на Zenodo. Ниже приведены DOI, который всегда ведёт к последней версии, и DOI, закреплённый за версией, которую вы читаете.

Го Комура (2026). Как преобразовать YUV в RGB с помощью Media Foundation. KomuraSoft LLC. https://doi.org/10.5281/zenodo.21619689 https://comcomponent.com/ru/blog/2026/03/15/002-media-foundation-yuv-to-rgb-conversion-patterns/

DOI (последняя версия)
10.5281/zenodo.21619689
DOI (эта версия)
10.5281/zenodo.21619690

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

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

В более ранней статье «Введение в Media Foundation: как понять API через COM» мы разобрали общую картину, а в статье «Как извлечь статичное изображение из MP4 по заданному времени с помощью Media Foundation» — извлечение статичных кадров. В этот раз разберём то, что лежит между этими темами: само преобразование 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)

Что нужно, чтобы запустить код

Если переносить код этой статьи в свой проект, достаточно следующего.

Пункт Требование
OS Windows 10 и новее
Компилятор MSVC из Visual Studio 2019 / 2022 (C++17)
SDK Windows SDK (заголовки Media Foundation и import-библиотеки). Входит в рабочую нагрузку Visual Studio «Разработка классических приложений на C++»
Сборка Для примеров — CMake 3.20 и новее. Проект Visual Studio можно собрать и вручную

Библиотек для линковки четыре. В коде статьи они заданы через #pragma comment(lib, ...), но то же самое можно указать в настройках проекта.

  • mfplat.lib
  • mfreadwrite.lib
  • mfuuid.lib
  • ole32.lib

Код этой статьи предполагает, что CoInitializeEx и MFStartup уже выполнены. Формула преобразования одного пикселя (5.6.) от ОС не зависит, поэтому в примерах на GitHub она вынесена в отдельный заголовок и тестируется и на Linux через g++.

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 и конвертируйте сами. Выбор из этих двух вариантов.

Два варианта этой статьиЕсли хочется проще — пусть Source Reader выдаёт RGB32; если нужна массовая обработка или контроль над цветом — принимайте YUV и конвертируйте сами.хочется прощемассовая обработка и контроль цветаНужен кадр в RGBПаттерн A: пусть Source Reader выдаёт RGB32Паттерн B: принять YUV и конвертировать самим

Рис. 1: Паттерн A, если важна простота; паттерн B, если берёте на себя объём обработки и ответственность за цвет.

Карта знаний этой статьи

Статья отталкивается от того, что несжатые кадры decoder Media Foundation — это не RGB, а Y’CbCr вроде NV12 или YUY2, и разбирает два пути преобразования YUV→RGB. Автоматическое преобразование Source Reader с MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING и запросом RGB32 удобно, но это software-обработка и она не подходит для воспроизведения в реальном времени; если нужна массовая обработка или контроль над цветом, NV12/YUY2 принимают сами и конвертируют в BGRA. В собственном преобразовании, помимо восстановления chroma subsampling, смотрят MF_MT_YUV_MATRIX (BT.601/BT.709) и MF_MT_VIDEO_NOMINAL_RANGE у IMFMediaType, чтобы выбрать верные коэффициенты и range, а stride не выводят из width×bytesPerPixel — берут фактическое значение, которое возвращает IMF2DBuffer::Lock2D.

Карта знаний преобразования YUV→RGB в Media FoundationРисунок двух путей преобразования YUV-кадров вроде NV12/YUY2, которые выдаёт decoder Media Foundation, в RGB и ловушек реализации: chroma subsampling, matrix, nominal range и strideтребуетнастраиваетсянастраиваетсяиспользуетиспользуетиспользуетиспользуетиспользуетреализуетиспользуетрекомендуется длятребуетпроверяетсярекомендуется дляиспользуетиспользуетнастраиваетсятребуетиспользуетиспользуетиспользуетиспользуетиспользуетпреобразование YUV → RGBформат пикселей NV12формат пикселей YUY2chroma subsampling (4:4:4/4:2:2/4:2:0)атрибут MF_MT_YUV_MATRIXатрибут MF_MT_VIDEO_NOMINAL_RANGEY'CbCrIMFSourceReader (Source Reader)MF_SOURCE_READER_ENABLE_VIDEO_PROCESSINGMFVideoFormat_RGB32Video Processor MFTstride (байты на строку изображения)IMF2DBuffer::Lock2DIMFMediaTypeIMFAttributesBT.601BT.709Media Foundation

На схеме сплошная линия обозначает отношение, которое выполняется всегда, а пунктирная — условное отношение (условия указаны в пояснении к каждому отношению на странице сведений). Полный список отношений (всего 23, с доказательствами и степенью уверенности) и определения основных понятий собраны на странице сведений карты знаний (на японском). Данные: JSON-LD / Turtle

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

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

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

Рис. 2: Decoder отдаёт кадры YUV; дальше путь к RGB расходится на паттерн A и паттерн B.

Если содержимое видеофайла представлено в сжатом формате вроде 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-форматы получили такое широкое распространение.

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

Рис. 3: Под чувствительность глаза к яркости YUV-форматы хранят Y подробно, а U/V грубее.

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

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

Сначала закрепим термин. stride (он же pitch) — это число байт на одну строку. Это не ширина изображения сама по себе, а «сколько байт до начала следующей строки», включая padding в конце строки. В этой статье stride и pitch значат одно и то же. На Microsoft Learn встречаются оба слова, отдельно их читать не нужно.

На схемах ниже W — width, H — height, S — stride. Главное: S >= W, и равенство S == W не гарантировано.

NV12 (4:2:0, planar) / width = W, height = H, stride = S

  <----------- S байт ----------->
  <--- W --->
 +-----------+---------------------+  --+
 | Y Y Y Y Y | (padding)           |    |
 | Y Y Y Y Y | (padding)           |    | Y plane
 | Y Y Y Y Y | (padding)           |    | S * H байт
 | Y Y Y Y Y | (padding)           |    |
 +-----------+---------------------+  --+  <- граница plane = S * H от начала
 | U V U V U | (padding)           |    |
 | U V U V U | (padding)           |    | UV plane
 +-----------+---------------------+  --+  высота H / 2 строк

  Y строки y     : yPlane  + S * y
  UV строки y    : uvPlane + S * (y / 2)
  начало UV plane : scanline0 + S * H

В NV12 4 пикселя блока 2x2 используют общую пару U/V. Y есть у каждого пикселя отдельно. UV plane использует тот же stride, что и Y plane, но число строк вдвое меньше. Поэтому граница plane — это S * H, а не W * H (к этому ещё вернёмся в 7.5.).

YUY2 (4:2:2, packed) / width = W, height = H, stride = S

  <-------------- S байт ---------------->
  <------- W * 2 байт ------->
 +-----------------------------+----------+
 | Y0 U0 Y1 V0  Y2 U2 Y3 V2 …  | (padding)|   строка 0
 | Y0 U0 Y1 V0  Y2 U2 Y3 V2 …  | (padding)|   строка 1
 +-----------------------------+----------+

  начало строки y : scanline0 + S * y
  2 pixel = 4 байта (Y, U, Y, V)
  plane один (packed, границы нет)

В YUY2 2 пикселя по горизонтали используют общую пару U/V. Y0 и Y1 разные, а U0 и V0 общие. Поскольку формат packed, считать границу plane не нужно, но для перехода между строками всё равно используют stride.

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

Единица совместного использования U/V в NV12 и YUY2В NV12 4 пикселя блока 2x2 используют общую пару U/V, в YUY2 — 2 пикселя по горизонтали; сначала нужно решить, каким пикселям назначить эту общую пару.NV12 (4:2:0)4 пикселя 2x2 используют одну пару U/VYUY2 (4:2:2)2 пикселя по горизонтали используют одну пару U/VРешить, каким пикселям и как назначить U/V

Рис. 4: В обоих форматах 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
Три слоя, которые стоит держать в практическом кодеДля практического кода 8-bit SDR преобразование YUV в RGB проще понять, разбив его на три слоя: вернуть субдискретизацию, вернуть range и применить matrix.Кадр YUVВернуть субдискретизациюВернуть range (16..235 и т. д.)Применить matrix (601 / 709)RGB

Рис. 5: Преобразование — не три коэффициента, а три слоя: субдискретизация, range и matrix.

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

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

Поток, в котором цветовое пространство не угадываютЕсли молча угадывать 601 или 709 по разрешению, сдвиг цвета легко остаётся незамеченным; смотрят атрибуты MF_MT_YUV_MATRIX и MF_MT_VIDEO_NOMINAL_RANGE и явно пропускают только поддерживаемые комбинации.Молча угадать по разрешениюСдвиг цвета не даёт сбояНезамеченным попадает в эксплуатациюСмотреть атрибуты matrix и rangeПропускать только поддерживаемые комбинацииТак проще предотвратить тихие сбои

Рис. 6: Цветовое пространство не угадывают: смотрят атрибуты и явно пропускают только те комбинации, которые код умеет.

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.

Структура формулы преобразованияИз Y вычитают чёрный уровень 16, U и V рассматривают относительно центра 128, применяют коэффициенты выбранной matrix и обрезают результат.Из Y вычесть 16 (чёрный уровень)Применить коэффициенты matrixИз U / V вычесть 128 (центр)Обрезать в 0..255

Рис. 7: Запоминать стоит не коэффициенты, а структуру формулы: чёрный уровень 16 и центр 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. Если нужно обрабатывать сотни кадров в секунду, опираться на этот механизм не совсем правильно.

Когда автоматическое преобразование подходит и когда нетАвтоматическое преобразование Source Reader — software-обработка и не оптимизировано для playback: оно подходит для извлечения кадров, миниатюр и пакетной работы, но на него не стоит опираться, если нужно сотни кадров в секунду.подходитне опиратьсяАвтоматическое преобразование Source Readersoftware-обработкаСтатичные кадры, миниатюры, пакетная работаСотни кадров в секунду в реальном времени

Рис. 8: Автоматическое преобразование — software-обработка, поэтому его держат в узком круге инструментов на небольшое число кадров.

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.

Четыре шага включения автоматического преобразованияЧетыре шага автоматического преобразования: включить video processing в attributes и создать Reader, выбрать видео stream, запросить RGB32 и прочитать кадр через ReadSample.Включить video processing в attributesВыбрать видео streamЗапросить RGB32Прочитать через ReadSampleСразу после decoder кадр становится RGB32

Рис. 9: Этих четырёх шагов достаточно, чтобы video processing сразу после decoder довела кадр до 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, сделав его непрозрачным.

Как обращаться с 4-м байтом RGB32В Windows у RGB32 после B, G, R четвёртый байт может быть alpha или don't care; перед передачей в WIC как 32bppBGRA безопаснее заполнить его 0xFF и сделать непрозрачным.заполнить 0xFFпередать как естьПорядок байт RGB32 в памяти3 байта B, G, R4-й байт — alpha или don't careМожно передать в WIC как 32bppBGRAИногда получается прозрачным

Рис. 10: 4-й байт не зафиксирован, поэтому перед передачей в WIC его заполняют 0xFF и делают непрозрачным.

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

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

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

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

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

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

Компромисс собственного преобразованияСобственное преобразование берёт на себя ответственность за объём обработки и цвет в обмен на оптимизацию, подключение GPU и SIMD, явный контроль matrix и range и свободу выходного формата.Выбрать собственное преобразованиеВзять на себя объём обработки и цветСвобода оптимизации, GPU / SIMD и выходного форматаЯвный контроль matrix / range

Рис. 11: Собственное преобразование — выбор свободы производительности и цвета в обмен на ответственность.

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-преобразование как «принимаем всё подряд», цвет легко портится незаметно.

Общий поток собственного преобразованияПоследовательность собственного преобразования: запросить NV12 или YUY2, проверить реальный media type и атрибуты, lock buffer, найти Y/U/V каждого пикселя и записать BGRA через matrix.Запросить NV12 / YUY2Проверить реальный subtype и атрибутыСделать lock bufferНайти Y/U/V каждого пикселяПрименить matrix и записать BGRAЧем уже допущения, тем меньше шанс испортить цвет

Рис. 12: Собственное преобразование идёт как запрос, проверка, lock, ссылка и преобразование; чем уже допущения, тем безопаснее.

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.

Запрос и реальный вывод смотрят отдельноSubtype, запрошенный через SetCurrentMediaType, не обязательно принимается как есть, поэтому реальный вывод подтверждают через GetCurrentMediaType.не обязательно принимается как естьЗапросить subtypeРеальный выводПроверить через GetCurrentMediaTypeДальше писать обработку по проверенным значениям

Рис. 13: Запрос остаётся запросом; реальный вывод всегда подтверждают через 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», но если молча округлять такие случаи на практике, сдвиг цвета становится труднее заметить. По крайней мере в первой реализации безопаснее считать ошибкой любую неподдерживаемую комбинацию.

Когда вообще возвращается Unknown

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

  • В VUI у H.264 / HEVC нет информации о цвете. По спецификации, если colour_description_present_flag равен 0, matrix_coefficients трактуется как «не задано». Если decoder пройдёт без этой информации, вниз уйдёт и matrix как незаданная
  • Сырой YUV с устройства захвата или из старого контейнера. Это путь без описания цветового пространства
  • Бывает, что самого атрибута MF_MT_YUV_MATRIX нет. Тогда GetUINT32 не возвращает значение и падает с MF_E_ATTRIBUTENOTFOUND (в коде выше это сразу отсекается через FAILED(hr))

Здесь важно: Unknown — это не «известно, что это BT.709», а «неизвестно». Если на материал SD-разрешения наложить 709, цвет уедет; верно и обратное.

Дальше политика расходится на два варианта.

  • Строго отсекать (политика этой статьи): вернуть ошибку как неподдерживаемый случай и дать верхнему уровню решить, что «этот материал не поддерживается». Безопаснее явно сказать «не умеем», чем тихо сдвинуть цвет
  • Пропустить с заранее выбранным значением по умолчанию: если материал всё же нужно пропустить, запишите в лог, какое предположение сделали для Unknown. И явно укажите, что «601 / 709 решили по разрешению»

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

Развилка, когда matrix равна UnknownUnknown значит «неизвестно», а не «известно, что это BT.709»; политика либо строго отсекает ошибкой, либо пропускает с записью предположения в лог, но молча округлять нельзя.политика этой статьиесли всё же нужно пропуститьименно этого избегатьmatrix = Unknown, то есть неизвестноСтрого отсечь ошибкойПропустить, записав предположение в логМолча округлить

Рис. 14: Unknown значит «неизвестно»: либо отсекают, либо пропускают с записью в лог, но молча не округляют.

Для камер и источников на базе 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, позже всё тихо сломается.

Приоритет значений strideMF_MT_DEFAULT_STRIDE — минимальный stride, а у реального буфера stride может включать padding; если доступен Lock2D, его значение приоритетнее, а жёсткая формула от width опасна.если доступен, высший приоритетfallbackтихо ломаетсяactual stride, который возвращает Lock2DЗначение для доступа к bufferMF_MT_DEFAULT_STRIDE (минимум)Жёсткая формула от width

Рис. 15: Для перехода между строками в первую очередь берут фактический stride из Lock2D и не выводят его из 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;

    // Начало UV plane — позиция на «stride × height» байт дальше.
    // Это не width × height (см. схему в 3.2.)
    const BYTE* uvPlane =
        scanline0 + static_cast<size_t>(actualStride) * info.height;

    for (UINT32 y = 0; y < info.height; ++y)
    {
        // Между строками всегда шагаем в единицах stride
        const BYTE* yRow = yPlane + static_cast<size_t>(actualStride) * y;

        // 4:2:0: две строки по вертикали делят одну строку UV -> y / 2
        // UV plane использует тот же stride, что и Y plane
        const BYTE* uvRow = uvPlane + static_cast<size_t>(actualStride) * (y / 2);

        // На выходе плотный BGRA без padding, поэтому width * 4
        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];

            // В UV plane [U, V] чередуются.
            // Два пикселя по горизонтали делят одну пару, поэтому сначала (x / 2)
            // даёт номер пары; одна пара = 2 байта, поэтому * 2 переводит в смещение.
            // +0 — U, +1 — V.
            //   x = 0, 1 -> uvRow[0], uvRow[1]
            //   x = 2, 3 -> uvRow[2], uvRow[3]
            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.

Два варианта chroma upsamplingМинимальная реализация напрямую использует общую chroma на 4 пикселя в духе nearest-neighbor и часто достаточно практична; если важнее качество, аккуратнее сначала сделать upconversion 4:2:0 -> 4:2:2 -> 4:4:4.Минимальная реализация: общую chroma использовать как естьВизуально часто достаточно практичноСначала upconversion, затем преобразованиеАккуратнее по теории, приоритет качествуПорядок 4:2:0 → 4:2:2 → 4:4:4

Рис. 16: Когда достаточно минимальной реализации с общей chroma как есть, а когда лучше поэтапный upconversion.

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
Развилка во входной функцииИз sample извлекают непрерывный buffer и ветвятся: NV12 идёт в преобразование NV12, YUY2 — в преобразование YUY2, всё остальное — в ошибку.NV12YUY2всё остальноеIMFSampleИзвлечь непрерывный bufferВ преобразование NV12В преобразование YUY2Вернуть ошибку

Рис. 17: На входе делают непрерывный buffer, затем ветвятся в преобразование по subtype, а неподдерживаемое честно отдают в ошибку.

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

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.

5.11. Как убедиться, что преобразование получилось верным

Сбои цвета плохо видны глазу, поэтому «заработало» и «верно» проверяют отдельно. Порядок — два этапа.

Этап 1: подставить известные значения и сверить с ручным расчётом

Надёжнее не сразу гнать видео, а передать в ConvertLimitedYuvPixelToBgra известные Y/U/V. Ни видеофайл, ни Media Foundation здесь не нужны.

Для limited range BT.601 типичные Y/U/V цветов и ожидаемые значения по формуле из 5.6. такие.

Цвет Y U V Ожидаемый R G B
Чёрный 16 128 128 0 0 0
Белый 235 128 128 255 255 255
Красный 81 90 240 254 0 0
Синий 41 240 110 0 0 255

Для красного, например, в формулу входят C = 81 - 16 = 65, D = 90 - 128 = -38, E = 240 - 128 = 112, и получается:

R = 1.164383 * 65 + 1.596027 * 112       = 254.44  -> 254
G = 1.164383 * 65 - 0.391762 * (-38)
                  - 0.812968 * 112       =  -0.48  ->   0
B = 1.164383 * 65 + 2.017232 * (-38)     =  -0.97  ->   0

Выход идёт в порядке BGRA, поэтому байты — 00 00 FE FF.

Здесь важно, что красный получается 254, а не 255. Причина не в точности коэффициентов. Входные Y/U/V — уже округлённые целые.

Если теоретический красный (255, 0, 0) перевести в limited range BT.601, получится Y = 16 + 219 × 0.299 = 81.481, U = 90.203, а V ровно 240. В момент сохранения 8-bit выборки дробная часть пропадает, и остаётся Y = 81. Потерянные 0.481 при обратном ходе дают усадку 0.481 × 1.164383 ≒ 0.56. 255 − 0.56 = 254.44 — отсюда и число 254.44 выше. Даже при бесконечной точности коэффициентов останется 254.44; округление до 6 знаков после запятой проявляется ниже 4-го знака и на 8-bit выходе не видно.

Как в конце переводят в целое, тоже влияет на результат. ClampToByte в 5.6. сначала укладывает значение в [0, 255], затем отбрасывает дробную часть у value + 0.5, то есть это округление до ближайшего. При простом отбрасывании (static_cast<BYTE>(value)) этот красный останется 254, но значения у границы вроде синего B = 255.04 или красного R = 0.38 разъедутся на 1. Прежде чем сверяться с другой реализацией, выясните, какой у неё способ.

То есть причина, по которой допускают разницу ±1〜2, — не «разная точность коэффициентов», а два пункта: «при дискретизации пропала дробная часть» и «способ перевода в целое у реализаций разный». И наоборот, разница, которую этими двумя пунктами не объяснить, — настоящий дефект. Красный становится 250, красный и синий меняются местами, всплывают только тёмные участки — такие расхождения надо подозревать не в точности коэффициентов, а в допущениях преобразования (перепутали BT.601 и BT.709, full range и limited range, U и V, неверно прочитали stride). Если списать это на «вопрос точности», можно пропустить дефект, который ещё можно исправить.

Где допустима разница и где настоящий дефектРазницу плюс-минус 1–2 объясняют дробной частью, пропавшей при дискретизации, и разным способом перевода в целое; необъяснимая этими причинами разница — настоящий дефект, и тогда подозревают допущения преобразования.разница ±1〜2не объясняетсяСмотреть разницу с ожидаемымОбъясняется дробной частью дискретизации и способом перевода в целоеНастоящий дефектПодозревать допущения matrix, range, U/V, stride

Рис. 18: Небольшую разницу объясняют дискретизацией и переводом в целое; если этого не хватает, подозревают перепутанные допущения.

Как тест достаточно такой формы.

#include <cstdlib>  // std::abs

// Смотрим, укладывается ли разница с ожидаемым в tolerance.
// В ожидаемое пишут «теоретический цвет» (для красного 255, 0, 0).
// Дробную часть, пропавшую при дискретизации, и разный способ
// перевода в целое поглощает tolerance. Точность коэффициентов — не причина
static bool CheckPixel(
    BYTE y, BYTE u, BYTE v,
    MFVideoTransferMatrix matrix,
    int expectedR, int expectedG, int expectedB,
    int tolerance = 2)
{
    BYTE bgra[4] = {};
    if (FAILED(ConvertLimitedYuvPixelToBgra(y, u, v, matrix, bgra)))
    {
        return false;
    }

    return std::abs(static_cast<int>(bgra[2]) - expectedR) <= tolerance
        && std::abs(static_cast<int>(bgra[1]) - expectedG) <= tolerance
        && std::abs(static_cast<int>(bgra[0]) - expectedB) <= tolerance
        && bgra[3] == 255;  // alpha всегда непрозрачный
}

// Как вызывать (BT.601 limited range)
// CheckPixel(16, 128, 128, MFVideoTransferMatrix_BT601, 0, 0, 0);      // чёрный
// CheckPixel(235, 128, 128, MFVideoTransferMatrix_BT601, 255, 255, 255); // белый
// CheckPixel(81, 90, 240, MFVideoTransferMatrix_BT601, 255, 0, 0);     // красный (по формуле R=254)
// CheckPixel(41, 240, 110, MFVideoTransferMatrix_BT601, 0, 0, 255);    // синий

То же можно сделать и для BT.709. Коэффициенты другие, поэтому меняются и значения Y/U/V. Например, красный BT.709 — это Y=63, U=102, V=240. Если значения 601 просто прогнать через ветку 709, цвет уедет, поэтому тесты лучше держать отдельными строками — тогда перепутанную matrix ловят на месте.

В примерах на GitHub это преобразование одного пикселя вынесено в заголовок, не зависящий от ОС, поэтому тест работает и без Windows.

Этап 2: сверить выводы паттерна A и паттерна B

Когда один пиксель сходится, следующий шаг — кадр целиком. С одного и того же момента одного и того же видео берут по одному кадру двумя путями:

  1. Паттерн A (MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING + RGB32)
  2. Паттерн B (принять NV12 / YUY2 и конвертировать самим)

и сравнивают попиксельно.

Как смотреть разницу:
  Для каждого pixel взять |A.R - B.R|, |A.G - B.G|, |A.B - B.B|
  Вывести максимум и долю pixel, превысивших порог

Здесь полного совпадения ждать не стоит. Причин две.

  • Video processing на стороне Source Reader может делать chroma upsampling не nearest-neighbor. Собственная реализация в 5.7. — минимальная: общую chroma напрямую ставят на 4 пикселя, поэтому разница сильнее на краях
  • Различаются округление и промежуточная точность

Поэтому смотреть нужно не «совпадает ли», а каков узор расхождения.

Какая разница видна Что подозревать
Плоские участки совпадают, разница только на цветовых границах Разница chroma upsampling. Это ожидаемо
Весь кадр смещён равномерно Перепутаны matrix (601 / 709) или range (16..235 / 0..255)
Полосы, косой сдвиг Жёстко заданный stride. 7.2. и 7.5.
Красный и синий меняются местами Перепутаны BGRA и RGBA
Весь кадр прозрачный / выглядит чёрным 4-й байт не заполнен 0xFF. 7.1.

По «форме» разницы место, которое стоит подозревать, сужается довольно сильно. Если смещён весь кадр равномерно — формула или информация о цвете; если локально — индексы или stride.

Двухэтапная проверкаСначала известные Y/U/V передают в один пиксель и сверяют с ручным расчётом; если сходится, сверяют кадры паттерна A и паттерна B с одного момента одного видео и по форме разницы сужают, что подозревать.Этап 1: один пиксель сверить с ручным расчётомЭтап 2: сверить кадры двух путейПо форме разницы сузить, что подозреватьВидео и Media Foundation не нужны

Рис. 19: Если сначала пройти проверку одного пикселя, а затем сравнить кадры целиком, причину разницы можно сузить по её форме.

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

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

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

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

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

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

Практичный порядок работыСначала подтверждают корректную картинку автоматическим преобразованием, затем заменяют его на свой manual path — так проще понять, в каком месте картинка сломалась.Сначала подтвердить корректную картинку автоматическим преобразованиемЗатем заменить на manual pathЛегче локализовать место поломкиСразу взять на себя всёТрудно понять, где сломалось

Рис. 20: Если сначала закрепить верную картинку, а затем переходить к своей реализации, локализовать поломку проще.

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. (pitch — другое имя stride. Как сказано в 3.2., в этой статье они значат одно и то же.)

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. Если сделать это небрежно, цвет сместится или изображение будет повреждено.

Как находить границу plane в NV12Начало UV plane в NV12 задаётся произведением фактического stride и height; если резать по width × height, получаются сдвиг цвета и порча изображения.пройти stride × heightНачало буфера (Y plane)Начало UV planeРезать по width × heightСдвиг цвета и порча изображения

Рис. 21: Границу UV 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

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

Картина, которую стоит держать в головеЕсли уложить, что в NV12 U/V общие на 2x2, в YUY2 — на 2 пикселя по горизонтали, и к этим U/V и Y применяют matrix, загадочная последовательность байт начинает выглядеть осмысленными пикселями.NV12: U/V общие на 2x2К этим U/V и Y применить matrixYUY2: U/V общие на 2 пикселя по горизонталиБайты выглядят осмысленными пикселями

Рис. 22: Когда в голове есть единица совместного использования и matrix, байты YUV читаются прямо.

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

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

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

Microsoft Learn

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

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

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

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

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

Почему декодер 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, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.

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

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