Media Foundation에서 YUV를 RGB로 변환하는 방법

· 업데이트: · · Media Foundation, C++, Windows 개발, 동영상 처리, YUV

수정 이력(8건, 최종 수정 2026년 09월 03일)

이 글에 적용한 변경 사항의 기록입니다. 보관해 둔 수정 전 버전은 DOI가 부여된 고정 URL에서 읽을 수 있습니다.

permalink·저자 표기·지식 맵 래퍼·깨진 내부 링크 등 CI가 지적한 표시용 수정을 반영했습니다. 본문의 기술적인 주장은 바꾸지 않았습니다.
일본어 원문의 완역으로 다시 번역했습니다. 기존 한국어판은 원문의 일부만 옮긴 축약본이라 절, 표, Mermaid 그림, 그림 설명, FAQ가 빠져 있었습니다. 일본어 원문에 맞춰 이들을 모두 복원했고, 기술적인 주장은 일본어판과 같습니다. 수정 전 버전 보기 (DOI: 10.5281/zenodo.21635138)
두 변환 경로의 분기, 서브샘플링 구조, matrix와 range 취급, stride 읽는 법, 검증의 2단계 구성 등을 그림으로도 따라갈 수 있도록 Mermaid 그림 21점을 추가했습니다(본문 500~750자당 1그림 규약에 맞춘 것입니다). 본문 문장은 바꾸지 않았습니다.
글 맨 앞에 「이 글의 지식 맵」 절을 추가했습니다. 본문에서 다루는 개념과 그 관계를 요약·그림·상세 페이지 링크로 정리한 것입니다. 본문의 주장은 바꾸지 않았습니다.
외부 리뷰(1283건)에 대응해 본문을 업데이트했습니다. 개별 변경 내용은 아래 이력을 참조하세요.
검증 절차에서, 빨강이 255가 아니라 254가 되는 이유를 「계수를 소수 6자리로 반올림했기 때문」이라고 설명했던 것을 고쳤습니다. 이는 잘못이며, 무한 정밀도 계수로 계산해도 254.44가 됩니다. 원인은 입력 Y/U/V가 이미 반올림된 정수라는 점으로, 이론상 빨강의 Y는 81.481이고 이를 81로 저장한 시점에서 잃는 0.481이 되돌릴 때 0.481×1.164383≒0.56만큼 줄어듭니다. 아울러 마지막 정수화 방침(이 글의 `ClampToByte`는 반올림)도 결과에 영향을 준다는 점을 명시하고, 허용 오차의 이유를 「표본화에서 나머지가 떨어진다」「정수화 방침이 구현마다 다르다」의 두 가지로 정리했습니다. 이 두 가지로 설명되지 않는 차는 진짜 버그이며, 계수 정밀도가 아니라 변환의 전제를 의심해야 한다는 점도 덧붙였습니다.
올바르게 변환되었는지를 확인하는 절을 새로 만들었습니다. 글 안의 식에서 손계산한 알려진 값 표(계수를 소수 6자리로 반올림하므로 빨강은 255가 아니라 254가 된다고 명시하고 있습니다)와, 차가 나는 모양에서 원인을 찾는 표를 실었습니다. 실측값은 쓰지 않았습니다. 레이아웃 그림에 stride와 plane 경계를 보태고, stride와 pitch가 동의어임을 처음 나올 때 밝혔으며, 전제 환경 표를 추가했습니다.
본문의 관련 글 링크 문구가 링크 대상의 현재 제목과 어긋나 있던 것을, 실제 제목에 맞췄습니다. 본문 내용은 바꾸지 않았습니다.
최초 공개
이 글을 인용하기(DOI: 10.5281/zenodo.21635137)

이 글은 Zenodo에 보관되어 있습니다. 항상 최신 버전으로 연결되는 DOI와 지금 보고 있는 버전에 고정된 DOI를 아래에 함께 제시합니다.

Go Komura (2026). 「Media Foundation에서 YUV를 RGB로 변환하는 방법」. 합동회사 코무라소프트. https://doi.org/10.5281/zenodo.21635137 https://comcomponent.com/ko/blog/media-foundation-yuv-to-rgb-conversion-patterns/

DOI(최신 버전)
10.5281/zenodo.21635137
DOI(이 버전)
10.5281/zenodo.22217437

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

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

이전에 쓴 Media Foundation 입문 - COM 관점으로 API를 이해하기 에서는 전체상을, Media Foundation으로 MP4의 지정 시각에서 정지 이미지를 잘라내는 방법 에서는 정지 이미지 추출을 정리했습니다. 이번에는 그 중간에 있는 YUV -> RGB 변환 그 자체를 다룹니다.

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

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

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

이 글에 나오는 코드는 샘플 묶음(패턴 A / 패턴 B의 C++ 코드, CMake 구성, 픽셀 변환 테스트)으로 GitHub에 공개해 두었습니다.

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

실행에 필요한 전제 환경

이 글의 코드를 자신의 프로젝트로 가져간다면, 필요한 것은 다음뿐입니다.

항목 전제
OS Windows 10 이후
컴파일러 Visual Studio 2019 / 2022의 MSVC(C++17)
SDK Windows SDK(Media Foundation의 헤더와 import library). Visual Studio의 「C++를 사용한 데스크톱 개발」 워크로드에 포함됩니다
빌드 샘플은 CMake 3.20 이후. Visual Studio 프로젝트를 직접 만들어도 됩니다

링크하는 라이브러리는 4개입니다. 글의 코드에서는 #pragma comment(lib, ...) 로 적었지만, 프로젝트 설정 쪽에서 지정해도 같습니다.

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

참고로 이 글의 코드는 CoInitializeExMFStartup이 끝난 전제입니다. 1 pixel의 변환식(5.6.)만은 OS에 의존하지 않으므로, GitHub 샘플에서는 별도 헤더로 분리해 두었고, Linux의 g++에서도 테스트할 수 있게 되어 있습니다.

1. 먼저 결론

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

  • 몇 장의 정지 이미지 추출이나 썸네일 생성이라면, MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING을 켜고 MFVideoFormat_RGB32를 요구하는 것이 가장 편합니다
  • 다만 이 자동 변환은 software 처리이며, 실시간 재생용으로 최적화되어 있지 않습니다
  • 직접 변환을 작성한다면, 우선 NV12YUY2를 제대로 이해하는 것이 가장 빠른 길입니다
  • YUV -> RGB는 「계수 3개를 곱하면 끝」이 아니라, 실제로는 서브샘플링, range, matrix, stride가 얽힙니다
  • Media Foundation 문서에서는 YUV라는 말을 넓게 쓰지만, 디지털 video에서는 실질적으로 Y’CbCr을 가리킨다고 생각하고 읽으면 정리하기 쉽습니다
  • 실무에서 색을 망가뜨리기 쉬운 것은, MF_MT_YUV_MATRIXMF_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라는 두 갈래가 된다.

이 글의 지식 맵

이 글은 Media Foundation의 decoder에서 나오는 비압축 프레임이 RGB가 아니라 NV12나 YUY2 같은 Y’CbCr 계열이라는 점을 출발점으로, YUV→RGB 변환의 두 가지 경로를 정리합니다. Source Reader에서 MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING을 활성화해 RGB32를 요청하는 자동 변환은 손쉽지만 software 처리라 실시간 재생에는 맞지 않으며, 대량 처리나 색 제어가 필요하다면 NV12/YUY2를 직접 받아 BGRA로 변환합니다. 직접 변환에서는 chroma subsampling의 복원에 더해, IMFMediaType이 가진 MF_MT_YUV_MATRIX(BT.601/BT.709)와 MF_MT_VIDEO_NOMINAL_RANGE를 보고 계수와 range를 올바르게 선택해야 하며, stride를 width×bytesPerPixel로 단정하지 말고 IMF2DBuffer::Lock2D가 반환하는 실제 값을 사용해야 합니다.

Media Foundation에서의 YUV→RGB 변환 지식 맵Media Foundation의 decoder가 내놓는 NV12/YUY2 등 YCbCr 계열 프레임을 RGB로 변환하는 두 가지 경로와, chroma subsampling·matrix·nominal range·stride라는 구현상의 함정 사이의 관계를 보여주는 그림전제로 한다에서 구성할 수 있다에서 구성할 수 있다이용한다이용한다이용한다이용한다이용한다구현을 담당한다이용한다권장되는 대응전제로 한다에서 확인할 수 있다권장되는 대응이용한다이용한다에서 구성할 수 있다전제로 한다이용한다이용한다이용한다이용한다이용한다YUV→RGB 변환NV12 픽셀 형식YUY2 픽셀 형식크로마 서브샘플링(4:4:4/4:2:2/4:2:0)MF_MT_YUV_MATRIX 속성MF_MT_VIDEO_NOMINAL_RANGE 속성Y'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 / HEVCdecoderNV12 / YUY2 / YV12 등의 YUV 프레임Source Reader의 video processingRGB32직접 작성한 변환 코드BGRA / RGB

그림 2: decoder가 내놓는 것은 YUV 프레임이고, 거기서 RGB로 가는 길이 패턴 A와 패턴 B로 나뉜다.

동영상 파일의 내용이 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라는 말을 넓게 씁니다. 다만 디지털 video 맥락에서는 UCb, VCr로 읽어도 거의 문제 없습니다.

대략 말하면,

  • Y는 밝기에 가까운 성분
  • U / V는 색차 성분
  • RGB는 각 화소가 그대로 Red / Green / Blue를 가진다

는 관계입니다.

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

YUV 계열이 쓰이는 이유사람의 눈은 색의 세밀함보다 밝기의 세밀함에 민감하므로 Y를 세밀하게 U/V를 거칠게 가지는 설계가 효과가 있고, 이것이 YUV 계열 포맷이 자주 쓰이는 이유임을 나타낸 그림.사람의 눈은 밝기에 민감하다Y를 세밀하게, U/V를 거칠게 가진다YUV 계열 포맷이 쓰이는 이유

그림 3: 밝기에 민감한 눈의 특성에 맞춰, 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

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

여기서 용어를 하나 먼저 고정합니다. stride(별칭 pitch)는 1행분의 바이트 수입니다. 이미지의 가로 폭 자체가 아니라, 행 끝의 padding을 포함한 「다음 행의 시작까지 몇 바이트 진행하는가」를 나타냅니다. 이 글에서는 stridepitch를 같은 의미로 씁니다. 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
  행 y의 UV     : uvPlane + S * (y / 2)
  UV plane 시작 : scanline0 + S * H

NV12에서는 2x2 block의 4화소가 1세트의 U/V를 공유합니다. Y는 각 pixel마다 있습니다. UV plane은 Y plane과 같은 stride를 쓰지만, 행 수는 절반입니다. 그래서 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화소가 1세트의 U/V를 공유합니다. Y0Y1은 다르지만, U0V0은 공유입니다. packed이므로 plane 경계 계산은 필요 없지만, 행의 이동에는 역시 stride를 씁니다.

이 시점에서 보이는 것은, YUV -> RGB가 단순한 1 pixel 1 pixel 대체가 아니라는 점입니다. 먼저 공유된 U/V를 어느 pixel에 어떻게 할당할 것인가를 생각해야 합니다.

NV12와 YUY2의 공유 단위 대비NV12는 2x2 블록의 4화소가 1세트의 U/V를 공유하고, YUY2는 가로 2화소가 1세트의 U/V를 공유하므로, 공유된 U/V를 어느 화소에 할당할지를 먼저 생각해야 함을 나타낸 그림.NV12(4:2:0)2x2의 4화소가 U/V를 1세트 공유YUY2(4:2:2)가로 2화소가 U/V를 1세트 공유어느 화소에 어떻게 할당할지를 정한다

그림 4: 어느 포맷이든 U/V가 공유되어 있어, 변환은 1화소씩 대체로 끝나지 않는다.

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를, 각 pixel이 참조할 수 있는 형태로 넓힌다
  2. range를 되돌린다 video의 Y는 보통 16..235, U/V는 16..240을 쓰므로, 그 스케일링을 되돌린다
  3. matrix를 곱한다 BT.601이나 BT.709 등의 계수로 RGB로 변환한다

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

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

를 정하는 처리입니다.

실무 코드에서 잡는 3층8bit SDR의 실무 코드에서는 서브샘플링을 되돌리고, range를 되돌리고, matrix를 곱하는 3층으로 나누면 YUV에서 RGB로의 변환을 이해하기 쉽다는 것을 나타낸 그림.YUV 프레임서브샘플링을 되돌린다range를 되돌린다(16..235 등)matrix를 곱한다(601 / 709)RGB

그림 5: 변환은 계수 3개가 아니라, 서브샘플링·range·matrix의 3층으로 이루어진다.

3.4. BT.601과 BT.709를 대충 다루면, 슬며시 색이 어긋난다

Media Foundation 문서에서는 BT.601은 SDTV와 그 이하, BT.709는 SD를 넘는 video에서 우선되는 관계로 설명되어 있습니다.

다만 여기서 「해상도가 크니까 709겠지」라고 가만히 추측하는 것은 별로 좋지 않습니다. 색 어긋남은 크래시하지 않으므로, 눈치채지 못한 채 운영에 올라타기 쉽기 때문입니다.

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

  • MF_MT_YUV_MATRIX
  • MF_MT_VIDEO_NOMINAL_RANGE

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

색공간을 추측하지 않기 위한 흐름해상도에서 601인지 709인지를 가만히 추측하면 색 어긋남을 눈치채지 못한 채 운영에 올라타기 쉬우므로, MF_MT_YUV_MATRIX와 MF_MT_VIDEO_NOMINAL_RANGE를 보고 대응하는 조합만 명시적으로 통과시키는 흐름을 나타낸 그림.해상도에서 가만히 추측한다색 어긋남은 크래시하지 않는다눈치채지 못한 채 운영에 올라탄다matrix와 range 속성을 본다대응하는 조합만 통과시킨다조용한 사고를 막을 수 있다

그림 6: 색공간은 추측하지 않고 속성을 보고, 대응할 수 있는 조합만 명시적으로 통과시킨다.

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을 중심으로 본다는 구조입니다.

변환식의 구조Y에서 블랙 레벨 16을 빼고, U와 V는 128을 중심으로 보고, matrix마다 계수를 곱하고, 결과를 clip하는 변환식의 구조를 나타낸 그림.Y에서 16을 뺀다(블랙 레벨)matrix 계수를 곱한다U / V에서 128을 뺀다(중심)0..255로 clip한다

그림 7: 외울 것은 계수가 아니라, 블랙 레벨 16과 중심 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에도 있듯이, 이것은 software 처리이며 playback용으로 최적화되어 있지 않습니다. 수백 장을 매초 처리하고 싶다면, 여기에 기대는 것은 조금 다릅니다.

자동 변환의 맞음과 맞지 않음Source Reader의 자동 변환은 software 처리이며 playback용으로 최적화되어 있지 않으므로, 정지 이미지 추출이나 썸네일 같은 배치 용도에는 맞지만 매초 수백 장 처리에 기대는 것은 다르다는 것을 나타낸 그림.맞는다기대지 않는다Source Reader의 자동 변환software 처리정지 이미지·썸네일·배치매초 수백 장의 실시간 처리

그림 8: 자동 변환은 software 처리이므로, 소수 프레임의 도구 용도로 좁혀 쓴다.

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단계attributes에서 비디오 처리를 켜서 Reader를 만들고, 동영상 stream을 고르고, RGB32를 요구하고, ReadSample로 읽는 자동 변환의 4단계를 나타낸 그림.attributes에서 비디오 처리를 켠다동영상 stream을 고른다RGB32를 요구한다ReadSample로 읽는다decoder 뒤에서 RGB32로 변환된다

그림 9: 4단계만 밟으면, decoder 뒤에 들어가는 video processing이 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
구현 software 처리
맞는 용도 소수 frame, 썸네일, 오프라인 처리
맞지 않는 용도 D3D 기반 real-time rendering, 대량 frame 처리
궁합이 나쁜 속성 MF_SOURCE_READER_D3D_MANAGER, MF_READWRITE_DISABLE_CONVERTERS

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

RGB32의 네 번째 바이트 취급Windows의 RGB32는 B·G·R 다음 네 번째 바이트가 alpha인지 don't care인지 정해져 있지 않으므로, WIC에 32bppBGRA로 넘기기 전에 0xFF로 채워 불투명하게 하는 편이 안전함을 나타낸 그림.0xFF로 채운다그대로 넘긴다RGB32의 메모리 배열B·G·R의 3바이트네 번째 바이트는 alpha 또는 don't care32bppBGRA로 WIC에 넘길 수 있다투명해질 때가 있다

그림 10: 네 번째 바이트는 정해져 있지 않으므로, 0xFF로 불투명하게 한 뒤 WIC에 넘긴다.

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

5. 패턴 B: 직접 변환 처리를 작성한다

5.1. 어떤 때에 맞는가

직접 변환이 맞는 것은, 예를 들어 이런 경우입니다.

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

처리량이나 색의 책임을 직접 지는 대신, 자유도를 가져가는 패턴이라고 할 수 있습니다.

직접 변환의 트레이드오프직접 변환은 처리량이나 색의 책임을 직접 지는 대신, 최적화나 GPU·SIMD로의 연결, matrix와 range의 명시적 제어, 출력 포맷의 자유도를 가져가는 패턴임을 나타낸 그림.직접 변환을 고른다처리량과 색의 책임을 직접 진다최적화·GPU / SIMD·출력 형식의 자유matrix / range의 명시적 제어

그림 11: 직접 변환은 책임과 맞바꿔, 성능과 색의 자유도를 가져가는 선택이 된다.

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. 각 pixel이 참조하는 Y/U/V를 구한다
  6. matrix를 곱해 BGRA에 쓴다

이 글의 코드는 8-bit SDR / progressive / NV12 or YUY2 / limited range로 좁힙니다. 여기서 전제를 좁히는 것은 대충 넘어가는 것이 아니라, 오히려 중요합니다. YUV 변환은 「일단 전부 받는」 구현으로 가면, 조용히 색을 망가뜨리기 쉽기 때문입니다.

직접 변환의 전체 흐름출력을 NV12나 YUY2로 하고, 실제 미디어 타입과 속성을 확인한 뒤, buffer를 lock해서 각 화소의 Y/U/V를 구하고, matrix를 곱해 BGRA에 쓰는 직접 변환의 순서를 나타낸 그림.NV12 / YUY2를 요구한다실제 subtype과 속성을 확인한다buffer를 lock한다각 화소의 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_NV12MFVideoFormat_YUY2를 넘깁니다.

주의할 점은, 요구한 subtype이 그대로 통과한다고는 할 수 없다는 것입니다. 실제로 무엇이 나오는지는 GetCurrentMediaType으로 확인합니다.

요구와 실제 출력을 나눠 본다SetCurrentMediaType으로 요구한 subtype이 그대로 통과한다고는 할 수 없으므로, 실제로 무엇이 나오는지를 GetCurrentMediaType으로 확인하는 흐름을 나타낸 그림.그대로 통과한다고는 할 수 없다subtype을 요구한다실제 출력GetCurrentMediaType으로 확인한다확인한 값으로 이후 처리를 작성한다

그림 13: 요구는 요구로 두고, 실제 출력은 반드시 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로 취급한다」는 식의 기술도 있지만, 실무에서는 여기를 가만히 뭉개면 색 어긋남을 눈치채기 어려워집니다. 적어도 처음 구현에서는 대응하지 않는 조합은 에러로 하는 편이 안전합니다.

Unknown은 어떤 때에 돌아오는가

「너무 엄격하지 않은가」라고 생각할 수 있으니, Unknown이 나오는 경로를 적어 둡니다. 대략 「원본 영상이 색 정보를 가지고 있지 않다」 경우입니다.

  • H.264 / HEVC의 VUI에 색 정보가 들어 있지 않다. 규격상 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를 대면 색이 어긋나고, 그 반대도 어긋납니다.

그 위에서 방침은 2가지로 갈립니다.

  • strict하게 걸러 낸다(이 글의 방침): 대응 외로 에러를 반환하고, 상위가 「이 소재는 비대응」이라고 판단하게 한다. 색이 조용히 어긋나는 것보다, 다룰 수 없다고 잘라 말하는 편이 안전합니다
  • 기본값을 정해 통과시킨다: 어떻게든 통과시켜야 한다면, Unknown일 때 어떻게 가정했는지를 로그에 남긴다. 그 위에서 「해상도로 601 / 709를 고정했다」는 것을 명시합니다

어느 쪽이든, 가만히 뭉개는 것만은 피합니다. 색 어긋남은 크래시하지 않으므로, 눈치채지 못한 채 운영에 올라탑니다.

matrix가 Unknown이었을 때의 분기Unknown은 BT.709라고 알고 있는 것이 아니라 모른다는 뜻이며, strict하게 에러로 걸러 내거나 기본값으로 통과시킨다면 가정을 로그에 남기는 2방침으로 갈리고, 가만히 뭉개는 것만은 피한다는 것을 나타낸 그림.이 글의 방침통과시켜야 한다면이것만은 피한다matrix가 Unknown = 모른다strict하게 에러로 걸러 낸다가정한 내용을 로그에 남기고 통과시킨다가만히 뭉갠다

그림 14: Unknown은 「모른다」이므로, 걸러 내거나 로그를 남기고 통과시킬지를 고르고, 가만히 뭉개지 않는다.

카메라나 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 access는 API가 반환한 stride(= pitch)를 그대로 쓰는 편이 안전합니다. 여기서 width 기반으로 고정하면, 나중에 조용히 깨집니다.

stride의 우선순위MF_MT_DEFAULT_STRIDE는 최소 stride이며, 실제 버퍼는 padding을 포함한 stride를 가질 수 있으므로, Lock2D를 쓸 수 있다면 그 값을 우선하고 width 기반 고정은 피한다는 우선순위를 나타낸 그림.쓸 수 있다면 최우선fallback조용히 깨진다Lock2D가 반환하는 actual stridebuffer access에 쓰는 값MF_MT_DEFAULT_STRIDE(최솟값)width 기반 고정

그림 15: 행 이동에 쓰는 stride는 Lock2D의 실측값을 최우선하고, 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의 네 번째 바이트는 255로 한다

5.7. NV12를 BGRA32로 변환한다

NV12는 4:2:0이므로, 2x2 block의 4 pixel이 같은 U/V를 공유합니다. 최소 구현으로는, 그 shared chroma를 그대로 4 pixel에 쓰는 것이 가장 알기 쉽습니다.

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는 세로 2행이 1행을 공유한다 -> y / 2
        // UV plane도 Y plane과 같은 stride를 쓴다
        const BYTE* uvRow = uvPlane + static_cast<size_t>(actualStride) * (y / 2);

        // 출력 쪽은 padding 없이 꽉 채운 BGRA이므로 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]가 번갈아 늘어선다.
            // 가로 2 pixel이 1세트를 공유하므로, 먼저 (x / 2)로 「몇 번째 세트인지」를 내고,
            // 1세트 = 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적으로 해석하고 있습니다. 겉모습은 충분히 실용적인 경우가 많지만, 최고 화질을 노린다면 Microsoft Learn의 YUV 글에 있듯이 4:2:0 -> 4:2:2 -> 4:4:4의 upconversion을 먼저 하는 설계가 이치로는 더 깨끗합니다.

chroma upsampling의 두 설계최소 구현은 공유 chroma를 그대로 4화소에 쓰는 nearest-neighbor적 해석으로 충분히 실용적인 경우가 많고, 최고 화질을 노린다면 4:2:0에서 4:2:2를 거쳐 4:4:4로 upconversion한 뒤 변환하는 설계가 이치로는 깨끗함을 나타낸 그림.최소 구현: 공유 chroma를 그대로 쓴다겉모습은 충분히 실용적인 경우가 많다upconversion한 뒤 변환한다이치로는 깨끗하다·화질 우선4:2:0 → 4:2:2 → 4:4:4 순

그림 16: 공유 chroma를 그대로 쓰는 최소 구현과, 단계적으로 upconversion하는 설계의 구분.

5.8. YUY2를 BGRA32로 변환한다

YUY2는 packed인 4:2:2입니다. 2 pixel이 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 pixel마다 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

라는 흐름으로 만들 수 있습니다.

입구 함수에서의 분기sample에서 연속 buffer를 꺼내고, subtype이 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 뒤에서 앱 쪽이 변환하는 형태입니다. 이것이 가장 알기 쉽습니다.

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

  • 독자 MFT를 작성한다
  • Video Processor MFT / XVP를 쓴다
  • GPU 쪽에서 NV12 -> RGB shader를 작성한다

이 정도까지 가면 주제가 조금 바뀌므로, 이번에는 앱 쪽 코드로 좁혔습니다. 다만 「Media Foundation에 맡긴다」와 「전부 앱에서 한다」 사이에 Video Processor MFT라는 중간 지점이 있다는 것은 알아 두면 편리합니다.

5.11. 올바르게 변환되었는지를 확인한다

색이 깨지는 문제는 눈에 잘 보이지 않으므로, 「동작했다」와 「올바르다」를 나눠 확인합니다. 순서는 다음 2단계입니다.

1단계: 알려진 값을 넣어 손계산과 대조한다

처음부터 동영상을 재생하기보다, ConvertLimitedYuvPixelToBgra에 알려진 Y/U/V를 넘기는 편이 확실합니다. 동영상 파일도 Media Foundation도 필요 없습니다.

BT.601의 limited range에서, 대표적인 색의 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입니다.

여기서 빨강이 255가 아니라 254가 되어 있는 점이 중요합니다. 이유는 계수의 정밀도가 아닙니다. 입력 Y/U/V가 이미 반올림된 정수이기 때문입니다.

이론상의 빨강 (255, 0, 0)BT.601의 limited range로 떨어뜨리면, Y = 16 + 219 × 0.299 = 81.481, U = 90.203, V는 정확히 240이 됩니다. 8bit 표본으로 저장하는 시점에서 이 나머지가 사라져 Y = 81이 됩니다. 잃어버린 0.481이, 되돌릴 때 0.481 × 1.164383 ≒ 0.56의 감소가 됩니다. 255 − 0.56 = 254.44 ── 위의 254.44는 여기서 나온 숫자입니다. 계수를 무한 정밀도로 해도 254.44 그대로이며, 6자리 반올림이 영향을 주는 것은 소수점 아래 4자리보다 아래라서, 8bit 출력에는 나타나지 않습니다.

마지막에 어떻게 정수로 떨어뜨리는가도 결과를 좌우합니다. 5.6.의 ClampToByte[0, 255]에 넣은 뒤 value + 0.5를 버림하는, 즉 반올림입니다. 단순한 버림(static_cast<BYTE>(value))으로 하면, 이 빨강은 같은 254이지만, 파랑의 B = 255.04나 빨강의 R = 0.38처럼 경계에 붙은 값에서 1이 어긋납니다. 다른 구현과 맞추기 전에, 상대가 어느 쪽인지를 확인하세요.

즉, ±1~2의 차를 허용하는 이유는 「계수 정밀도가 달라서」가 아니라, 「표본화에서 나머지가 떨어졌기 때문」과 「정수화 방침이 구현마다 다르기 때문」의 2가지입니다. 반대로 말하면, 이 2가지로 설명되지 않는 차는 진짜 버그입니다. 빨강이 250이 된다, 빨강과 파랑이 바뀐다, 암부만 뜬다 ── 이런 차는 계수 정밀도가 아니라 변환의 전제(BT.601BT.709의 혼동, full range와 limited range의 혼동, UV의 교체, 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 샘플에서는 이 1 pixel 변환만 OS 비의존 헤더로 분리해 두었으므로, Windows가 없어도 이 테스트는 동작합니다.

2단계: 패턴 A와 패턴 B의 출력을 대조한다

1 pixel이 맞으면, 다음은 프레임 전체입니다. 같은 동영상의 같은 시각에서,

  1. 패턴 A(MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING + RGB32
  2. 패턴 B(NV12 / YUY2를 받아 직접 변환)

의 2경로로 1장씩 꺼내, pixel마다 비교합니다.

차이를 보는 법:
  각 pixel의 |A.R - B.R|, |A.G - B.G|, |A.B - B.B|를 취한다
  최댓값과, 임계값을 넘은 pixel의 비율을 낸다

여기서 완전 일치는 기대하지 마세요. 이유는 2가지입니다.

  • Source Reader 쪽 video processing이 chroma upsampling을 nearest-neighbor 이외의 방법으로 하고 있을 가능성이 있습니다. 5.7.의 직접 구현은 공유 chroma를 그대로 4 pixel에 쓰는 최소 구현이므로, 에지일수록 차가 납니다
  • 반올림과 중간 정밀도 취급이 다릅니다

그래서 볼 것은 「일치하는가」가 아니라, 차가 나는 모양의 패턴입니다.

보인 차 의심할 곳
평탄한 부분은 일치, 색 경계만 차가 난다 chroma upsampling의 차이. 예상 범위
전체가 한결같이 어긋난다 matrix (601 / 709) 또는 range (16..235 / 0..255)의 혼동
줄무늬가 된다, 비스듬히 어긋난다 stride 고정. 7.2.와 7.5.
빨강과 파랑이 바뀐다 BGRA와 RGBA의 혼동
전체가 투명 / 새까맣게 보인다 네 번째 바이트를 0xFF로 채우지 않음. 7.1.

차이의 「모양」을 보면, 어디를 의심하면 될지는 꽤 좁혀집니다. 전체가 한결같이 어긋나면 식이나 색 정보, 국소적이면 인덱스나 stride입니다.

검증의 2단계 구성먼저 알려진 Y/U/V를 1화소만 넘겨 손계산과 대조하고, 맞으면 같은 동영상의 같은 시각에서 패턴 A와 패턴 B의 출력 프레임을 대조하며, 차의 모양에서 어디를 의심할지를 좁히는 2단계 검증을 나타낸 그림.1단계: 1화소를 손계산과 대조한다2단계: 2경로의 프레임을 대조한다차의 모양에서 어디를 의심할지를 좁힌다동영상도 Media Foundation도 필요 없다

그림 19: 1화소 검증을 통과한 뒤 프레임 전체를 비교하면, 차의 원인을 모양으로 좁힐 수 있다.

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를 alpha가 있는 RGBA라고 단정한다

RGB32는 메모리 상에서 B, G, R, Alpha or Don't Care입니다. 그대로 BGRA로 PNG에 넣으면, 네 번째 바이트가 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가 반환하는 값을 우선합니다. (pitchstride의 별칭입니다. 3.2.에서 말했듯이, 이 글에서는 같은 의미로 씁니다.)

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가 아닙니다. 여기를 대충 하면 색이 어긋나거나, 이미지가 깨지거나 합니다.

NV12의 plane 경계를 구하는 법NV12의 UV plane 시작은 실제 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 sample은 progressive 전제입니다. interlaced를 그대로 1 field로 읽으면, 빗살 노이즈가 나올 때가 있습니다. deinterlace가 필요하면, Source Reader의 자동 video processing이나 Video Processor MFT를 시야에 넣는 편이 솔직합니다.

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

이 글의 NV12 변환은 알기 쉬움 우선으로 shared chroma를 그대로 각 pixel에 쓰는 형태입니다. 용도에 따라서는 충분하지만, 화질 우선이라면 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 pixel로 U/V를 공유
  • 그 U/V와 Y에 matrix를 곱한다

는 그림이 머릿속에 들어오면 꽤 명확해집니다. 정체를 알 수 없던 바이트 배열이, 제대로 의미 있는 화소로 보이기 시작합니다.

머릿속에 넣어 둘 그림NV12는 2x2로 U/V를 공유하고, YUY2는 가로 2화소로 U/V를 공유하며, 그 U/V와 Y에 matrix를 곱한다는 그림이 머릿속에 들어오면 수수께끼 바이트 배열이 의미 있는 화소로 보인다는 것을 나타낸 그림.NV12: 2x2로 U/V를 공유그 U/V와 Y에 matrix를 곱한다YUY2: 가로 2화소로 U/V를 공유바이트 배열이 의미 있는 화소로 보인다

그림 22: 공유 단위와 matrix라는 두 점이 잡히면, YUV 바이트 배열은 바로 읽힌다.

9. 참고 자료

이 글의 샘플 코드

KomuraSoft의 관련 글

Microsoft Learn

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

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

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

Windows 앱 개발

Media Foundation, Source Reader, 이미지 저장, 동영상 프레임 변환을 포함한 Windows 미디어 처리 구현 주제라서, Windows 앱 개발과 잘 맞는 내용입니다.

자주 묻는 질문

이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.

Media Foundation의 decoder는 왜 RGB가 아니라 YUV를 내놓나요?
사람의 눈은 색의 세밀함보다 밝기의 세밀함에 민감하기 때문에, video에서는 Y(밝기에 가까운 성분)를 세밀하게, U/V(색차 성분)를 거칠게 가지는 설계가 효과가 있기 때문입니다. 그래서 Windows의 video 계열에서는 decoder가 내놓는 비압축 프레임이 NV12나 YUY2 같은 YUV 계열 포맷인 것이 보통입니다. 참고로 디지털 video 맥락에서는 YUV를 실질적으로 Y'CbCr을 가리킨다고 읽으면 정리하기 쉽습니다.
RGB 프레임을 얻는 가장 간단한 방법은 무엇인가요?
IMFSourceReader에서 MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING을 켜고 MFVideoFormat_RGB32를 요구하는 방법입니다. 몇 장의 정지 이미지 추출이나 썸네일 생성이라면 이 방법이 가장 편합니다. 다만 이 자동 변환은 software 처리이며 실시간 재생용으로 최적화되어 있지 않으므로, 대량 처리나 색 제어까지 원하면 YUV 그대로 받아 직접 변환합니다.
직접 YUV에서 RGB로 변환할 때의 주의점은 무엇인가요?
계수 3개를 곱하면 끝나는 것이 아니라, 서브샘플링(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 뒤에 U와 V가 번갈아 늘어선 UV plane이 이어지며, 2x2 블록의 4화소가 한 세트의 U/V를 공유합니다. YUY2는 4:2:2 포맷으로, 가로 방향 2화소가 U/V를 공유합니다. 둘 다 실무에서 자주 나오는 포맷이며, 색을 솎아내는 방식(서브샘플링)이 다릅니다.

저자 프로필

기사 저자의 프로필 페이지입니다.

Go Komura

합동회사 코무라소프트 대표

Windows 소프트웨어 개발, 기술 상담, 장애 조사를 중심으로 재현이 어려운 장애 조사와 기존 자산이 남아 있는 프로젝트에 강점이 있습니다.

블로그 목록으로 돌아가기