Media Foundation에서 YUV 프레임을 RGB로 변환하는 방법 - Source Reader의 자동 변환과 직접 변환을 원리부터 정리

· · Media Foundation, C++, Windows 개발, 동영상 처리, YUV

동영상에서 프레임을 뽑아 PNG로 저장하고 싶다, WIC나 GDI에 넘기고 싶다, 또는 UI에 띄우고 싶다. 그런 장면에서 앱 쪽은 RGB 픽셀 배열을 원합니다.

그런데 Media Foundation의 decoder에서 나오는 프레임은 꽤 평범하게 NV12YUY2 같은 YUV 계열 포맷 입니다. 여기서 날것의 바이트 배열을 그대로 이미지라고 생각하고 다루면 색이 망가지거나 줄무늬가 생기거나 묘하게 녹색이 돌거나 하는, 조금 서글픈 그림이 됩니다.

이전에 쓴 Media Foundation이란 무엇인가 - COM과 Windows 미디어 API의 얼굴이 보이는 이유 에서는 전체상을, Media Foundation으로 MP4 동영상의 지정 시각에서 정지 이미지를 뽑는 방법 - .cpp에 그대로 붙일 수 있는 1파일 완결판 에서는 정지 이미지 추출을 정리했습니다. 이번에는 그 중간에 있는 YUV -> RGB 변환 그 자체 를 다룹니다.

이 글에서는 다음 2패턴을 나눠 정리합니다.

  • 패턴 A: IMFSourceReader에게 RGB32까지 자동으로 데려가게 한다
  • 패턴 B: NV12 / YUY2를 받아 직접 RGB로 변환한다

노리는 것은 API 이름 암기가 아닙니다. Media Foundation의 어디서 YUV가 나오고 어디서 RGB로 바뀌는지, 그 흐름을 머릿속에 그릴 수 있게 하는 것입니다.

1. 먼저 결론

먼저 결론만 정리하면 이렇습니다.

  • 몇 장의 정지 이미지 추출이나 썸네일 생성 이라면 MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING을 활성화하고 MFVideoFormat_RGB32를 요구하는 것이 가장 편합니다
  • 단, 이 자동 변환은 소프트웨어 처리 이며 리얼타임 재생용으로 최적화되지 않았습니다
  • 직접 변환을 쓴다면 먼저 NV12YUY2를 제대로 이해하는 것이 최단입니다
  • YUV -> RGB는 「계수 3개 곱하면 끝」이 아니라 실제로는 서브샘플링, range, matrix, stride 가 얽힙니다
  • Media Foundation 문서에서는 YUV라는 말을 널리 쓰지만 디지털 비디오에서는 실질적으로 Y’CbCr 을 가리킨다고 생각하며 읽으면 정리하기 쉽습니다
  • 실무에서 색을 망가뜨리기 쉬운 건 MF_MT_YUV_MATRIXMF_MT_VIDEO_NOMINAL_RANGE를 보지 않는 것, 그리고 stride를 width * bytesPerPixel로 믿어버리는 것 입니다

요컨대 편하게 가고 싶다면 Source Reader에게 RGB32를 내놓게 한다. 대량 처리나 색 제어까지 원하면 YUV 그대로 받아 직접 변환한다. 이 두 갈래입니다.

2. 먼저 그림으로 본다

먼저 Media Foundation 안에서 무슨 일이 일어나고 있는지 그림으로 보는 편이 빠릅니다.

flowchart LR
    File["MP4 / H.264 / HEVC"] --> Decoder["decoder"]
    Decoder --> YUV["NV12 / YUY2 / YV12 등의 YUV 프레임"]
    YUV -->|패턴 A| SRVP["Source Reader의 video processing"]
    SRVP --> RGB1["RGB32"]
    YUV -->|패턴 B| App["직접 만든 변환 코드"]
    App --> RGB2["BGRA / RGB"]

동영상 파일의 내용이 H.264나 HEVC 같은 압축 형식이라면 먼저 decoder가 그것을 비압축 프레임 으로 되돌립니다. 이 비압축 프레임이 RGB라고는 할 수 없습니다. 오히려 Windows의 video 계열에서는 YUV 계열이 보통 입니다.

그래서 앱이 RGB를 원할 때는 다음 중 하나를 고릅니다.

  1. Media Foundation 쪽에 RGB32까지 데려가게 한다
  2. YUV를 받아 직접 코드로 RGB로 한다

이 글의 이야기는 바로 이 분기점입니다.

3. YUV와 RGB의 관계를 먼저 정리한다

3.1. YUV라고는 해도 실제로는 Y’CbCr 이야기

Windows의 API 이름이나 문서는 YUV라는 말을 널리 씁니다. 다만 디지털 비디오 문맥에서는 UCb, VCr로 읽어도 거의 문제없습니다.

대충 말하면,

  • Y는 밝기 쪽 성분
  • U / V는 색차 성분
  • RGB는 각 픽셀이 그대로 Red / Green / Blue를 가진다

는 관계입니다.

사람의 눈은 색의 세밀함보다 밝기의 세밀함에 민감합니다. 그래서 비디오에서는 Y를 세밀하게, U/V를 조금 거칠게 가지는 설계가 먹힙니다. 이게 YUV 계열 포맷이 자주 쓰이는 이유입니다.

3.2. 4:4:4 / 4:2:2 / 4:2:0은 「색을 얼마나 솎고 있는가」

여기가 YUV를 읽을 때의 핵심입니다.

표기 의미 대표 예
4:4:4 각 픽셀이 Y/U/V를 각각 가진다 AYUV, I444
4:2:2 가로 방향 2 픽셀이 U/V를 공유 YUY2, UYVY, I422
4:2:0 2x2 픽셀이 U/V를 공유 NV12, YV12, I420

실무에서 자주 나오는 2개만 모양을 봐 두면 꽤 편합니다.

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에서는 2x2 블록의 4 픽셀이 한 세트의 U/V를 공유 합니다. Y는 각 픽셀마다 있습니다.

YUY2 (4:2:2, packed)

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

YUY2에서는 가로 2 픽셀이 한 세트의 U/V를 공유 합니다. Y0Y1은 다르지만 U0V0는 공유입니다.

이 시점에서 보이는 건, YUV -> RGB가 단순한 1픽셀 1픽셀의 치환이 아니라는 점입니다.
먼저 공유되고 있는 U/V를 어느 픽셀에 어떻게 할당할지 를 생각해야 합니다.

3.3. YUV -> RGB는 「색공간 변환 + 샘플링 변환」

Media Foundation의 Extended Color Information 을 보면 엄밀한 색 변환은 꽤 단계가 많습니다. inverse quantization, chroma upsampling, YUV -> RGB, transfer function, primaries 변환, quantization까지 나옵니다.

다만 8-bit SDR의 실무 코드 로 먼저 잡는다면 다음 3층으로 나누면 이해가 쉬워집니다.

  1. 서브샘플링을 되돌린다
    4:2:0이나 4:2:2의 U/V를 각 픽셀이 참조할 수 있는 형태로 펼친다
  2. range를 되돌린다
    video의 Y는 보통 16..235, U/V는 16..240을 쓰므로 그 스케일링을 되돌린다
  3. matrix를 곱한다
    BT.601이나 BT.709 등의 계수로 RGB로 변환한다

즉 YUV -> RGB 변환이란 실무적으로는

  • 어떤 U/V가 그 픽셀의 색인가
  • 그 Y/U/V를 어떤 계수로 RGB로 되돌릴 것인가

를 정하는 처리입니다.

3.4. BT.601과 BT.709를 거칠게 다루면 살짝살짝 색이 어긋난다

Media Foundation 문서에서는 BT.601은 SDTV 이하, BT.709는 SD를 넘는 비디오에서 우선되는 관계로 설명합니다.

하지만 여기서 「해상도가 크니까 709겠지」하고 묵묵히 추측하는 건 그다지 좋지 않습니다. 색 어긋남은 크래시하지 않으므로 모르는 채로 운용에 실리기 쉽기 때문입니다.

Media Foundation에서는 색공간 정보를 미디어 타입 속성으로 가질 수 있습니다. 최소한 다음 2개는 봅니다.

  • MF_MT_YUV_MATRIX
  • MF_MT_VIDEO_NOMINAL_RANGE

이 2개를 보고 내 코드가 대응하고 있는 조합만 명시적으로 통과시키는 편이 나중에 조용히 사고 나기 어렵습니다.

3.5. 먼저 외울 식은 BT.601의 limited range 버전

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에서 정지 이미지를 1장 뽑고 싶다
  • 썸네일을 몇 장 만들고 싶다
  • RGB 이미지로 만들어 WIC에 넘기고 싶다
  • 리얼타임 재생이 아니라 배치나 도구용으로 충분하다

Source Reader 에는 MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING으로 YUV -> RGB32의 limited한 video processing 을 시키는 기능이 있습니다.

다만 Microsoft Learn에도 있듯 이것은 소프트웨어 처리 이며 playback용으로 최적화되지 않았습니다. 매초 수백 장을 처리하고 싶다면 여기 기대는 건 조금 다릅니다.

4.2. 무엇을 설정하면 RGB32가 나오는가

흐름은 꽤 자연스럽습니다.

  1. MFCreateSourceReaderFromURL에 넘기는 attributes로 MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING = TRUE
  2. 동영상 stream을 고른다
  3. SetCurrentMediaType으로 MFMediaType_Video / MFVideoFormat_RGB32를 요구한다
  4. ReadSample로 sample을 읽는다

이것만으로 decoder 뒤에 들어가는 limited video processing이 YUV -> RGB32 를 해 줍니다.

4.3. 코드

아래 코드는 CoInitializeExMFStartup이 끝난 전제입니다. 최소 구성이라면 대체로 이런 형태입니다.

#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을 부르면 실제 출력 size나 stride를 확인할 수 있습니다.

4.4. 이 방법의 강점

이 방법의 장점은 어쨌든 빨리 올바른 그림에 가까워지는 것입니다.

  • 4:2:0 / 4:2:2의 전개를 직접 쓰지 않아도 된다
  • matrix / deinterlace의 번거로움을 꽤 숨길 수 있다
  • WIC나 GDI에 넘기기 쉽다
  • 몇 프레임 처리라면 충분히 실용적

정지 이미지 추출 계열 도구에서는 먼저 여기서 들어가는 게 꽤 자연스럽습니다.

4.5. 다만 함정도 있다

이 자동 변환에는 다음 성질이 있습니다.

항목 내용
변환 대상 기본은 RGB32
구현 소프트웨어 처리
맞는 용도 소수 프레임, 썸네일, 오프라인 처리
맞지 않는 용도 D3D 기반의 real-time rendering, 대량 프레임 처리
상성 나쁜 속성 MF_SOURCE_READER_D3D_MANAGER, MF_READWRITE_DISABLE_CONVERTERS

그리고 하나 더 중요한 게 RGB32의 4바이트째 취급입니다.
Windows의 RGB32는 메모리상 Blue / Green / Red / Alpha or Don’t Care 순서입니다. ARGB32가 아닙니다. WIC에 32bppBGRA로 넘긴다면 4바이트째를 0xFF로 채워 불투명 으로 하는 편이 안전합니다.

여기는 이전 정지 이미지 추출 글에서도 밟기 쉬운 점으로 다뤘습니다.

5. 패턴 B: 직접 변환 처리를 쓴다

5.1. 어떤 때 맞는가

직접 변환이 맞는 건 예컨대 이런 케이스입니다.

  • 대량 프레임을 처리하므로 변환을 직접 최적화하고 싶다
  • NV12 그대로 GPU나 SIMD로 흘리고 싶다
  • BT.601 / BT.709 / range를 명시적으로 다루고 싶다
  • RGB32 이외의 출력 포맷을 만들고 싶다
  • Source Reader의 limited 자동 변환으로는 부족

요컨대 처리량이나 색의 책임을 스스로 지는 대신 자유도를 잡으러 가는 패턴입니다.

5.2. 직접 변환의 전체 플로

절차는 다음과 같습니다.

  1. Source Reader의 출력을 NV12YUY2로 한다
  2. GetCurrentMediaType으로 실제 subtype과 속성을 취득
  3. MF_MT_FRAME_SIZE, MF_MT_DEFAULT_STRIDE, MF_MT_YUV_MATRIX, MF_MT_VIDEO_NOMINAL_RANGE를 확인
  4. sample에서 buffer를 꺼내 lock
  5. 각 픽셀이 참조할 Y/U/V를 구한다
  6. matrix를 곱해 BGRA에 쓴다

이 글의 코드는 8-bit SDR / progressive / NV12 or 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_NV12MFVideoFormat_YUY2를 넘깁니다.

주의하고 싶은 건 요구한 subtype이 그대로 통하는 건 아니다 는 점입니다. 실제로 무엇이 나오는지는 GetCurrentMediaType으로 확인합니다.

5.4. 변환 전에 대응하는 색 정보만 받아들인다

직접 변환에서는 먼저 미디어 타입에서 최소한의 정보를 취합니다.
이 글의 샘플에서는 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 하게 했습니다.
Media Foundation의 enum 문서에는 「UnknownBT.709로 취급」이라는 기술도 있지만, 실무에서는 여기를 묵묵히 둥글리면 색 어긋남을 놓치기 쉬워집니다. 적어도 첫 구현에서는 대응하지 않는 조합은 에러로 하는 편이 안전합니다.

카메라나 JPEG 계열에서는 full-range를 가지는 경로를 따로 다루고 싶을 때가 있습니다. 여기서는 그걸 묵묵히 겸용하지 않고 이 코드가 받아들이는 전제를 명시적으로 좁히는 방침으로 하고 있습니다.

5.5. buffer는 stride를 믿고 읽는다

여기도 꽤 중요합니다.

  • MF_MT_DEFAULT_STRIDE최소 stride
  • 실제 sample buffer는 padding을 포함한 actual stride 를 가질 수 있다
  • IMF2DBuffer::Lock2D를 쓸 수 있다면 그걸 우선한다

Microsoft Learn의 Uncompressed Video Buffers 에 있는 helper 패턴을 그대로 쓰기 좋게 하면 다음과 같습니다.

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 / positive stride이지만 실제 buffer 접근은 API가 돌려준 pitch를 그대로 쓰는 편이 안전합니다. 여기서 width 기반으로 결정 짓기 하면 나중에 조용히 망가집니다.

5.6. 1 pixel의 변환식을 코드로 한다

여기서는 BT.601BT.709의 limited range만 다룹니다. 출력은 WIC나 GDI에 넘기기 쉬운 BGRA32 로 합니다.

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별 계수를 곱한다
  • 결과를 0..255에 clip한다
  • BGRA의 4바이트째는 255로 한다

5.7. NV12를 BGRA32로 변환한다

NV12는 4:2:0이므로 2x2 블록의 4 픽셀이 같은 U/V를 공유 합니다.
최소 구현으로는 그 shared 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적으로 해석 하고 있습니다.
겉보기는 충분히 실용적인 경우가 많지만 최고 화질을 노린다면 Microsoft Learn의 YUV 글에 있듯 4:2:0 -> 4:2:2 -> 4:4:4의 upconversion 을 먼저 수행하는 설계 쪽이 이론적으로는 깔끔합니다.

5.8. YUY2를 BGRA32로 변환한다

YUY2는 packed한 4:2:2입니다.
2 픽셀에서 1조의 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는 bytes가 Y0 U Y1 V로 늘어서므로 「2 픽셀마다 U/V를 돌려쓴다」는 구조가 그대로 보입니다.
이만큼 NV12보다 mental model을 만들기 쉽습니다.

5.9. sample에서 부를 때의 입구

마지막으로 IMFSample에서 연속 buffer를 꺼내 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를 만든다
  • NV12YUY2를 요구한다
  • GetCurrentMediaType에서 DecodedFrameInfo를 만든다
  • 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 이후에 앱 쪽이 변환하는 형태입니다. 이게 가장 명료합니다.

다만 Media Foundation 파이프라인 안에 끼워 넣고 싶다면 다른 설계도 있습니다.

  • 자체 MFT를 쓴다
  • Video Processor MFT / XVP를 쓴다
  • GPU 쪽에서 NV12 -> RGB shader를 쓴다

이 근처까지 가면 주제가 조금 달라지므로 이번에는 앱 쪽 코드에 좁혔습니다.
다만 「Media Foundation에 맡기기」와 「전부 앱에서 하기」 사이에 Video Processor MFT 라는 중간 지점이 있다, 는 건 알아 두면 편리합니다.

6. 어느 쪽을 고를까

망설일 때는 다음 표로 꽤 정리됩니다.

관점 자동 변환 (MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING) 직접 변환
구현 속도
몇 장의 정지 이미지 추출
대량 프레임 / real-time
matrix / range 명시 제어 원함
GPU / D3D와 결합 ○〜◎
RGB32 이외의 출력 원함
원리 이해

첫 1개로는 이렇게 생각하면 편합니다.

  • 먼저 움직이고 싶다 -> 자동 변환
  • 색이나 성능의 책임을 지고 싶다 -> 직접 변환

실무에서는 「먼저 자동 변환으로 올바른 그림을 확인하고 그 뒤에 manual path로 바꾼다」는 순서도 꽤 유효합니다. 처음부터 전부 짊어지면 어디서 그림이 망가졌는지 알기 어려워지기 때문입니다.

7. 실무에서 밟기 쉬운 함정

7.1. RGB32를 alpha 있는 RGBA로 믿는다

RGB32는 메모리상 B, G, R, Alpha or Don't Care입니다.
그대로 BGRA로 PNG로 저장하면 4바이트째가 0이어서 투명한 경우가 있습니다. 저장 전에 0xFF를 넣는 편이 안전합니다.

7.2. width * bytesPerPixel로 stride를 결정 짓기

꽤 흔한 사고입니다.
실제 sample buffer에는 padding이 들어갈 수 있으므로 row 간 이동은 actual stride를 쓰는 것이 원칙입니다.

7.3. MF_MT_DEFAULT_STRIDE와 actual pitch를 혼동한다

MF_MT_DEFAULT_STRIDE는 「그 format을 연속 메모리로 나타냈을 때의 최소 stride」입니다.
sample buffer의 actual pitch는 IMF2DBuffer::Lock2D가 돌려주는 값을 우선합니다.

7.4. color metadata를 보지 않고 601 / 709를 묵묵히 추측

색 사고는 눈에 잘 안 보입니다. 크래시도 안 합니다. 그래서 성가십니다.

  • MF_MT_YUV_MATRIX
  • MF_MT_VIDEO_NOMINAL_RANGE

는 적어도 봅시다.
그리고 내 코드가 대응하지 않는 값은 에러로 하는 정도의 마음가짐이 딱 좋습니다.

7.5. NV12의 UV plane을 width * height로 자른다

plane offset은 실제 stride와 height 로 결정됩니다. width * height가 아닙니다.
여기를 거칠게 하면 색이 어긋나거나 이미지가 망가집니다.

7.6. interlaced video를 progressive 전제로 처리

이 글의 manual 샘플은 progressive 전제입니다. interlaced를 그대로 1 field로 읽으면 빗살 노이즈가 날 수 있습니다.
deinterlace가 필요하다면 Source Reader의 자동 video processing이나 Video Processor MFT를 시야에 넣는 편이 자연스럽습니다.

7.7. 4:2:0의 chroma upsampling 품질을 무시

이 글의 NV12 변환은 명료성 우선으로 shared chroma를 그대로 각 픽셀에 쓰는 형태입니다. 용도에 따라서는 충분하지만 화질 우선이라면 YUV 권장 포맷 자료에 있는 upconversion 사고방식까지 쫓는 편이 좋습니다.

8. 정리

Media Foundation에서 YUV에서 RGB로 변환할 때는 먼저 다음 정리를 가지고 있으면 꽤 덜 헤맵니다.

  • decoder 뒤에서는 RGB가 아니라 NV12YUY2가 평범하게 나온다
  • 편하게 가고 싶다면 MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING으로 RGB32를 요구한다
  • 제어하고 싶다면 NV12 / YUY2를 받아 직접 BGRA로 변환한다
  • manual path에서는 식보다 먼저 sampling / range / matrix / stride 를 잡는다
  • BT.601 / BT.709, 16..235, 4:2:0 / 4:2:2를 모호하게 하면 색 어긋남이나 망가진 그림이 된다

YUV -> RGB는 처음엔 조금 다루기 어렵습니다.
하지만 한번,

  • NV12는 2x2로 U/V를 공유
  • YUY2는 가로 2 픽셀로 U/V를 공유
  • 그 U/V와 Y에 matrix를 곱한다

는 이미지가 머리에 들어오면 꽤 자연스러워집니다. 우주색의 수수께끼 바이트 배열이 제대로 의미 있는 픽셀로 보이기 시작합니다.

9. 참고 자료

합동회사 고무라소프트 관련 글

Microsoft Learn

관련 기사

같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.

관련 토픽

이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.

이 주제와 연결되는 서비스

이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.

블로그 목록으로 돌아가기