Media Foundation에서 YUV 프레임을 RGB로 변환하는 방법 - Source Reader의 자동 변환과 직접 변환을 원리부터 정리
· 업데이트: · 小村 豪 · Media Foundation, C++, Windows 개발, 동영상 처리, YUV
수정 이력(1건, 최종 수정 2026년 09월 01일)
이 글에 적용한 변경 사항의 기록입니다. 보관해 둔 수정 전 버전은 DOI가 부여된 고정 URL에서 읽을 수 있습니다.
- 일본어 원문의 완역으로 다시 번역했습니다. 기존 한국어판은 원문의 일부만 옮긴 축약본이라 절, 표, Mermaid 그림, 그림 설명, FAQ가 빠져 있었습니다. 일본어 원문에 맞춰 이들을 모두 복원했고, 기술적인 주장은 일본어판과 같습니다. 수정 전 버전 보기 (DOI: 10.5281/zenodo.21635138)
- 최초 공개
이 글을 인용하기(DOI: 10.5281/zenodo.21635137)
이 글은 Zenodo에 보관되어 있습니다. 항상 최신 버전으로 연결되는 DOI와 지금 보고 있는 버전에 고정된 DOI를 아래에 함께 제시합니다.
小村 豪 (2026). 「Media Foundation에서 YUV 프레임을 RGB로 변환하는 방법 - Source Reader의 자동 변환과 직접 변환을 원리부터 정리」. 합동회사 코무라소프트. https://doi.org/10.5281/zenodo.21635137 https://comcomponent.com/ko/blog/2026/03/15/002-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에서 나오는 프레임은 꽤 평범하게 NV12나 YUY2 같은 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 라이브러리). Visual Studio의 「C++를 사용한 데스크톱 개발」 워크로드에 포함되어 있습니다 |
| 빌드 | 샘플은 CMake 3.20 이상. Visual Studio 프로젝트를 손으로 만들어도 상관없습니다 |
링크할 라이브러리는 4개입니다. 글의 코드에서는 #pragma comment(lib, ...)로 적었지만, 프로젝트 설정 쪽에서 지정해도 동일합니다.
mfplat.libmfreadwrite.libmfuuid.libole32.lib
참고로 이 글의 코드는 CoInitializeEx와 MFStartup이 끝난 상태를 전제로 합니다. 1 pixel 변환식(5.6.)만은 OS에 의존하지 않으므로, GitHub 샘플에서는 별도 헤더로 빼 두었고 Linux의 g++로도 테스트할 수 있게 했습니다.
1. 먼저 결론
먼저 결론만 정리하면 이렇습니다.
- 몇 장의 정지 이미지 추출이나 썸네일 생성이라면
MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING을 활성화하고MFVideoFormat_RGB32를 요구하는 것이 가장 편합니다 - 다만 이 자동 변환은 software 처리이며 리얼타임 재생용으로 최적화되어 있지 않습니다
- 직접 변환을 쓴다면 먼저
NV12와YUY2를 제대로 이해하는 것이 최단입니다 - YUV -> RGB는 「계수 3개만 곱하면 끝」이 아니라 실제로는 서브샘플링, 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 그대로 받아 직접 변환한다. 이 두 갈래입니다.
flowchart TB
accTitle: 이 글의 두 갈래
accDescr: 편하게 가고 싶다면 Source Reader가 RGB32를 내놓게 하고, 대량 처리나 색 제어까지 원한다면 YUV 그대로 받아 직접 변환한다는, 이 글이 다루는 두 갈래를 보여주는 그림.
want1["RGB 프레임이 필요하다"] -->|"편하게 가고 싶다"| pa1["패턴 A: Source Reader가 RGB32를 내놓게 한다"]
want1 -->|"대량 처리·색 제어"| pb1["패턴 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가 반환하는 실제 값을 사용해야 합니다.
flowchart LR
accTitle: Media Foundation에서의 YUV→RGB 변환 지식 맵
accDescr: Media Foundation의 decoder가 내놓는 NV12/YUY2 등 YCbCr 계열 프레임을 RGB로 변환하는 두 가지 경로와, chroma subsampling·matrix·nominal range·stride라는 구현상의 함정 사이의 관계를 보여주는 그림
yuv_to_rgb_conversion["YUV→RGB 변환"]
nv12_pixel_format["NV12 픽셀 형식"]
yuy2_pixel_format["YUY2 픽셀 형식"]
chroma_subsampling["크로마 서브샘플링(4:4:4/4:2:2/4:2:0)"]
yuv_matrix_attribute["MF_MT_YUV_MATRIX 속성"]
nominal_range_attribute["MF_MT_VIDEO_NOMINAL_RANGE 속성"]
ycbcr["Y'CbCr"]
imfsourcereader["IMFSourceReader(Source Reader)"]
mf_video_processing_auto_conversion["MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING"]
mfvideoformat_rgb32["MFVideoFormat_RGB32"]
video_processor_mft["Video Processor MFT"]
video_stride["stride(이미지 행 바이트 수)"]
imf2dbuffer_lock2d["IMF2DBuffer::Lock2D"]
imfmediatype["IMFMediaType"]
imfattributes["IMFAttributes"]
bt601["BT.601"]
bt709["BT.709"]
media_foundation["Media Foundation"]
yuv_to_rgb_conversion -->|"전제로 한다"| chroma_subsampling
yuv_to_rgb_conversion -.->|"에서 구성할 수 있다"| yuv_matrix_attribute
yuv_to_rgb_conversion -.->|"에서 구성할 수 있다"| nominal_range_attribute
nv12_pixel_format -->|"이용한다"| chroma_subsampling
yuy2_pixel_format -->|"이용한다"| chroma_subsampling
nv12_pixel_format -->|"이용한다"| ycbcr
yuy2_pixel_format -->|"이용한다"| ycbcr
imfsourcereader -.->|"이용한다"| mf_video_processing_auto_conversion
mf_video_processing_auto_conversion -->|"구현을 담당한다"| yuv_to_rgb_conversion
mf_video_processing_auto_conversion -->|"이용한다"| mfvideoformat_rgb32
video_processor_mft -.->|"권장되는 대응"| yuv_to_rgb_conversion
yuv_to_rgb_conversion -->|"전제로 한다"| video_stride
video_stride -.->|"에서 확인할 수 있다"| imf2dbuffer_lock2d
imf2dbuffer_lock2d -->|"권장되는 대응"| yuv_to_rgb_conversion
imfsourcereader -->|"이용한다"| imfmediatype
imfmediatype -->|"이용한다"| imfattributes
video_stride -.->|"에서 구성할 수 있다"| imfmediatype
nv12_pixel_format -->|"전제로 한다"| video_stride
yuv_matrix_attribute -->|"이용한다"| bt601
yuv_matrix_attribute -->|"이용한다"| bt709
media_foundation -.->|"이용한다"| imfsourcereader
yuv_to_rgb_conversion -.->|"이용한다"| nv12_pixel_format
yuv_to_rgb_conversion -.->|"이용한다"| yuy2_pixel_format
그림의 실선은 항상 성립하는 관계, 점선은 조건이 붙는 관계입니다(성립 조건은 상세 페이지의 관계별 설명에 적혀 있습니다). 관계 전체 목록(총 23건, 근거와 확신도 포함)과 주요 개념의 정의는 지식 맵 상세 페이지에 정리되어 있습니다(일본어). 데이터: JSON-LD / Turtle
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"]
그림 2: decoder가 내놓는 것은 YUV 프레임이며, 여기서 RGB로 가는 길이 패턴 A와 패턴 B로 갈라진다.
동영상 파일의 내용이 H.264나 HEVC 같은 압축 형식이라면, 먼저 decoder가 그것을 비압축 프레임으로 되돌립니다. 이 비압축 프레임이 RGB라고는 할 수 없습니다. 오히려 Windows의 video 계열에서는 YUV 계열이 보통입니다.
그래서 앱이 RGB를 원할 때는 다음 중 하나를 고릅니다.
- Media Foundation 쪽에서 RGB32까지 처리하게 한다
- YUV를 받아 직접 코드로 RGB로 만든다
이 글의 이야기는 바로 이 분기점입니다.
3. YUV와 RGB의 관계를 먼저 정리한다
3.1. YUV라고는 해도 실제로는 Y’CbCr 이야기
Windows의 API 이름이나 문서는 YUV라는 말을 널리 씁니다. 다만 디지털 video 문맥에서는 U를 Cb, V를 Cr로 읽어도 거의 문제없습니다.
대략 말하면,
Y는 밝기 쪽 성분U/V는 색차 성분RGB는 각 픽셀이 그대로 Red / Green / Blue를 가진다
는 관계입니다.
사람의 눈은 색의 세밀함보다 밝기의 세밀함에 민감합니다. 그래서 video에서는 Y를 세밀하게, U/V를 조금 거칠게 가지는 설계가 효과적입니다. 이것이 YUV 계열 포맷이 자주 쓰이는 이유입니다.
flowchart TB
accTitle: YUV 계열이 쓰이는 이유
accDescr: 사람의 눈은 색의 세밀함보다 밝기의 세밀함에 민감하므로 Y를 세밀하게 U/V를 거칠게 가지는 설계가 효과적이며 이것이 YUV 계열 포맷이 자주 쓰이는 이유임을 보여주는 그림.
eye1["사람의 눈은 밝기에 민감하다"] --> dsn1["Y를 세밀하게, U/V를 거칠게 가진다"]
dsn1 --> why1["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을 포함해 「다음 행의 시작까지 몇 바이트 나아가는가」를 나타냅니다. 이 글에서는 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
행 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은 1개뿐 (packed이므로 경계가 없다)
YUY2에서는 가로 2 화소가 1조의 U/V를 공유합니다. Y0와 Y1은 다르지만 U0와 V0는 공유입니다.
packed이므로 plane 경계 계산은 필요 없지만, 행 이동에는 역시 stride를 씁니다.
이 시점에서 보이는 것은, YUV -> RGB가 단순한 1 pixel 1 pixel 치환이 아니라는 점입니다. 먼저 공유되고 있는 U/V를 어느 pixel에 어떻게 할당할지를 생각해야 합니다.
flowchart TB
accTitle: NV12와 YUY2의 공유 단위 대비
accDescr: NV12는 2x2 블록의 4 화소가 1조의 U/V를 공유하고 YUY2는 가로 2 화소가 1조의 U/V를 공유하므로 공유된 U/V를 어느 화소에 할당할지 먼저 생각해야 함을 보여주는 그림.
nv1["NV12(4:2:0)"] --> sh1["2x2의 4 화소로 U/V를 1조 공유"]
yy1["YUY2(4:2:2)"] --> sh2["가로 2 화소로 U/V를 1조 공유"]
sh1 --> asn1["어느 화소에 어떻게 할당할지 결정"]
sh2 --> asn1
그림 4: 두 포맷 모두 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층으로 나누면 이해하기 쉽습니다.
- 서브샘플링을 되돌린다 4:2:0이나 4:2:2의 U/V를 각 pixel이 참조할 수 있는 형태로 넓힌다
- range를 되돌린다 video의 Y는 보통 16..235, U/V는 16..240을 쓰므로 그 스케일링을 되돌린다
- matrix를 곱한다
BT.601이나BT.709등의 계수로 RGB로 변환한다
즉 YUV -> RGB 변환이란 실무적으로는,
- 어느 U/V가 그 pixel의 색인가
- 그 Y/U/V를 어느 계수로 RGB로 되돌릴 것인가
를 정하는 처리입니다.
flowchart TB
accTitle: 실무 코드에서 잡아야 할 3층
accDescr: 8bit SDR의 실무 코드에서는 서브샘플링을 되돌리고 range를 되돌리고 matrix를 곱하는 3층으로 나누면 YUV에서 RGB로의 변환을 이해하기 쉬움을 보여주는 그림.
y1["YUV 프레임"] --> up1["서브샘플링을 되돌린다"]
up1 --> rg1["range를 되돌린다(16..235 등)"]
rg1 --> mx1["matrix를 곱한다(601 / 709)"]
mx1 --> rgb2["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_MATRIXMF_MT_VIDEO_NOMINAL_RANGE
이 2가지를 확인하고 자신의 코드가 대응하는 조합만 명시적으로 통과시키는 편이, 나중에 조용히 사고 나기 어렵습니다.
flowchart TB
accTitle: 색공간을 추측하지 않기 위한 흐름
accDescr: 해상도로 601인지 709인지 묵묵히 추측하면 색 어긋남을 눈치채지 못한 채 운영에 실리기 쉬우므로 MF_MT_YUV_MATRIX와 MF_MT_VIDEO_NOMINAL_RANGE를 확인해 대응하는 조합만 명시적으로 통과시키는 흐름을 보여주는 그림.
gs1["해상도로 묵묵히 추측"] --> sl1["색 어긋남은 크래시하지 않는다"]
sl1 --> op1["눈치채지 못한 채 운영에 실린다"]
at1["matrix와 range 속성을 확인"] --> ps1["대응하는 조합만 통과"]
ps1 -.-> sf1["조용한 사고를 막을 수 있다"]
그림 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을 중심으로 본다는 구조입니다.
flowchart TB
accTitle: 변환식의 구조
accDescr: Y에서 검은 레벨 16을 빼고 U와 V는 128을 중심으로 보아 matrix별 계수를 곱하고 결과를 clip한다는 변환식의 구조를 보여주는 그림.
yy2["Y에서 16을 뺀다(검은 레벨)"] --> co1["matrix의 계수를 곱한다"]
uv1["U / V에서 128을 뺀다(중심)"] --> co1
co1 --> cl1["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용으로 최적화되어 있지 않습니다. 매초 수백 장을 처리하고 싶다면 여기에 기대는 것은 조금 맞지 않습니다.
flowchart TB
accTitle: 자동 변환의 적합·부적합
accDescr: Source Reader의 자동 변환은 software 처리이며 playback용으로 최적화되어 있지 않으므로 정지 이미지 추출이나 썸네일 같은 배치 용도에는 적합하지만 매초 수백 장 처리에 기대는 것은 맞지 않음을 보여주는 그림.
auto1["Source Reader의 자동 변환"] --> sw1["software 처리"]
sw1 -->|"적합"| bat1["정지 이미지·썸네일·배치"]
sw1 -.->|"기대지 않는다"| rt1["매초 수백 장의 리얼타임 처리"]
그림 8: 자동 변환은 software 처리이므로, 소수 프레임을 다루는 도구 용도로 좁혀 사용한다.
4.2. 무엇을 설정하면 RGB32가 나오는가
흐름은 꽤 단순합니다.
MFCreateSourceReaderFromURL에 넘기는 attributes에서MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING = TRUE- 동영상 stream을 고른다
SetCurrentMediaType으로MFMediaType_Video/MFVideoFormat_RGB32를 요구한다ReadSample로 sample을 읽는다
이것만으로 decoder 뒤에 들어가는 limited한 video processing이 YUV -> RGB32를 해 줍니다.
flowchart TB
accTitle: 자동 변환을 활성화하는 4단계
accDescr: attributes에서 비디오 처리를 활성화해 Reader를 만들고 동영상 stream을 고르고 RGB32를 요구하고 ReadSample로 읽는다는 자동 변환의 4단계를 보여주는 그림.
a1["attributes에서 비디오 처리 활성화"] --> a2["동영상 stream을 고른다"]
a2 --> a3["RGB32를 요구한다"]
a3 --> a4["ReadSample로 읽는다"]
a4 -.-> a5["decoder 뒤에서 RGB32로 변환된다"]
그림 9: 이 4단계만 밟으면 decoder 뒤에 들어가는 video processing이 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,
×tamp,
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의 4바이트째 처리입니다.
Windows의 RGB32는 메모리상에서 Blue / Green / Red / Alpha or Don’t Care 순서입니다. ARGB32가 아닙니다. WIC에 32bppBGRA로 넘긴다면 4바이트째를 0xFF로 채워 불투명으로 만드는 편이 안전합니다.
flowchart TB
accTitle: RGB32의 4바이트째 처리
accDescr: Windows의 RGB32는 B·G·R 뒤의 4바이트째가 alpha인지 don't care인지 정해져 있지 않으므로 WIC에 32bppBGRA로 넘기기 전에 0xFF로 채워 불투명하게 만드는 편이 안전함을 보여주는 그림.
r32["RGB32의 메모리 배열"] --> bgr1["B·G·R의 3바이트"]
r32 --> b41["4바이트째는 alpha 또는 don't care"]
b41 -->|"0xFF로 채운다"| wic1["32bppBGRA로 WIC에 넘길 수 있다"]
b41 -.->|"그대로 넘긴다"| tr3["투명해질 수 있다"]
그림 10: 4바이트째는 정해져 있지 않으므로, 0xFF로 불투명하게 만든 뒤 WIC로 넘긴다.
이 부분은 이전 정지 이미지 추출 글에서도 밟기 쉬운 지점으로 다뤘습니다.
5. 패턴 B: 직접 변환 처리를 작성한다
5.1. 어떤 때 적합한가
직접 변환이 적합한 것은 예를 들어 다음과 같은 경우입니다.
- 대량 frame을 처리하므로 변환을 직접 최적화하고 싶다
NV12그대로 GPU나 SIMD로 흘려보내고 싶다BT.601/BT.709/ range를 명시적으로 다루고 싶다RGB32이외의 출력 포맷을 만들고 싶다- Source Reader의 limited한 자동 변환으로는 부족하다
처리량과 색의 책임을 스스로 지는 대신 자유도를 확보하러 가는 패턴이라고 할 수 있습니다.
flowchart TB
accTitle: 직접 변환의 트레이드오프
accDescr: 직접 변환은 처리량과 색의 책임을 스스로 지는 대신 최적화나 GPU·SIMD 연결, matrix와 range의 명시적 제어, 출력 포맷의 자유도를 확보하러 가는 패턴임을 보여주는 그림.
own1["직접 변환을 선택"] --> res1["처리량과 색의 책임을 스스로 진다"]
res1 --> fr1["최적화·GPU / SIMD·출력 형식의 자유"]
res1 --> fr2["matrix / range의 명시적 제어"]
그림 11: 직접 변환은 책임과 맞바꾸어 성능과 색의 자유도를 확보하는 선택이 된다.
5.2. 직접 변환의 전체 흐름
절차는 다음과 같습니다.
- Source Reader의 출력을
NV12나YUY2로 한다 GetCurrentMediaType으로 실제 subtype과 속성을 가져온다MF_MT_FRAME_SIZE,MF_MT_DEFAULT_STRIDE,MF_MT_YUV_MATRIX,MF_MT_VIDEO_NOMINAL_RANGE를 확인한다- sample에서 buffer를 꺼내 lock한다
- 각 pixel이 참조할 Y/U/V를 구한다
- matrix를 곱해 BGRA에 쓴다
이 글의 코드는 8-bit SDR / progressive / NV12 or YUY2 / limited range로 좁힙니다.
여기서 전제를 좁히는 것은 날림이 아니라 오히려 중요합니다. YUV 변환은 「일단 전부 받아들이는」 구현으로 만들면 조용히 색을 망가뜨리기 쉽기 때문입니다.
flowchart TB
accTitle: 직접 변환의 전체 흐름
accDescr: 출력을 NV12나 YUY2로 하고 실제 미디어 타입과 속성을 확인하고 buffer를 lock해 각 화소의 Y/U/V를 구하고 matrix를 곱해 BGRA에 쓴다는 직접 변환의 절차를 보여주는 그림.
f1["NV12 / YUY2를 요구"] --> f2["실제 subtype과 속성을 확인"]
f2 --> f3["buffer를 lock한다"]
f3 --> f4["각 화소의 Y/U/V를 구한다"]
f4 --> f5["matrix를 곱해 BGRA에 쓴다"]
f2 -.-> nar1["전제를 좁히면 색을 망가뜨리기 어렵다"]
그림 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으로 확인합니다.
flowchart TB
accTitle: 요구와 실제 출력을 나누어 본다
accDescr: SetCurrentMediaType으로 요구한 subtype이 그대로 통한다는 보장은 없으므로 실제로 무엇이 나오는지 GetCurrentMediaType으로 확인하는 흐름을 보여주는 그림.
req1["subtype을 요구한다"] -.->|"그대로 통한다는 보장은 없다"| out2["실제 출력"]
out2 --> gct1["GetCurrentMediaType으로 확인"]
gct1 --> use1["확인한 값으로 이후 처리를 작성"]
그림 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 문서에는 「Unknown은 BT.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를 결정지었다」는 것을 명시합니다
어느 쪽이든 묵묵히 둥글리는 것만은 피합니다. 색 어긋남은 크래시하지 않으므로 눈치채지 못한 채 운영에 실립니다.
flowchart TB
accTitle: matrix가 Unknown일 때의 분기
accDescr: Unknown은 BT.709라고 알고 있는 것이 아니라 모른다는 의미이며 strict하게 에러로 걸러내거나 기본값으로 통과시키려면 가정을 로그에 남기는 2가지 방침으로 나뉘고 묵묵히 둥글리는 것만은 피해야 함을 보여주는 그림.
unk1["matrix가 Unknown = 모른다"] -->|"이 글의 방침"| st4["strict하게 에러로 걸러낸다"]
unk1 -->|"통과시켜야 한다면"| dflt1["가정한 내용을 로그에 남기고 통과"]
unk1 -.->|"이것만은 피한다"| mute1["묵묵히 둥글린다"]
그림 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 기준으로 결정지으면 나중에 조용히 망가집니다.
flowchart TB
accTitle: stride의 우선순위
accDescr: MF_MT_DEFAULT_STRIDE는 최소 stride이며 실제 버퍼는 padding을 포함한 stride를 가질 수 있으므로 Lock2D를 쓸 수 있다면 그 값을 우선하고 width 기준의 결정짓기는 피해야 한다는 우선순위를 보여주는 그림.
p1["Lock2D가 반환하는 actual stride"] -->|"쓸 수 있다면 최우선"| acc1["buffer access에 쓰는 값"]
p2["MF_MT_DEFAULT_STRIDE(최솟값)"] -->|"fallback"| acc1
p3["width 기준의 결정짓기"] -.->|"조용히 망가진다"| acc1
그림 15: 행 이동에 쓰는 stride는 Lock2D의 실측값을 최우선하고, width로 결정짓지 않는다.
5.6. 1 pixel 변환식을 코드로 작성한다
여기서는 BT.601과 BT.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 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을 먼저 수행하는 설계 쪽이 이론적으로는 깔끔합니다.
flowchart TB
accTitle: chroma upsampling의 2가지 설계
accDescr: 최소 구현은 공유 chroma를 그대로 4화소에 쓰는 nearest-neighbor 방식의 해석으로 충분히 실용적인 경우가 많고 최고 화질을 노린다면 4:2:0에서 4:2:2를 거쳐 4:4:4로 upconversion한 뒤 변환하는 설계가 이론적으로 깔끔함을 보여주는 그림.
min1["최소 구현: 공유 chroma를 그대로 사용"] --> pr1["겉보기로 충분히 실용적인 경우가 많다"]
hq1["upconversion한 뒤 변환"] --> pr2["이론적으로 깔끔함·화질 우선"]
hq1 -.-> steps1["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를 만든다
NV12나YUY2를 요구한다GetCurrentMediaType에서DecodedFrameInfo를 만든다ReadSampleConvertSampleToBgra32
라는 흐름으로 만들 수 있습니다.
flowchart TB
accTitle: 입구 함수에서의 분기
accDescr: sample에서 연속 buffer를 꺼내 subtype이 NV12면 NV12용 변환으로 YUY2면 YUY2용 변환으로 분기하고 그 밖에는 에러로 처리한다는 입구 함수의 구조를 보여주는 그림.
smp1["IMFSample"] --> cont1["연속 buffer를 꺼낸다"]
cont1 -->|"NV12"| cnv1["NV12용 변환으로"]
cont1 -->|"YUY2"| cnv2["YUY2용 변환으로"]
cont1 -->|"그 밖"| err1["에러로 처리한다"]
그림 17: 입구에서 연속 buffer로 만든 뒤 subtype별 변환으로 분기하고, 대응 외는 그대로 에러로 떨어뜨린다.
실제 호출 쪽은 예를 들어 다음과 같이 됩니다.
ComPtr<IMFMediaType> currentType;
HRESULT hr = reader->GetCurrentMediaType(
MF_SOURCE_READER_FIRST_VIDEO_STREAM,
¤tType);
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,
×tamp,
&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.601과 BT.709의 혼동, full range와 limited range의 혼동, U와 V의 뒤바뀜, stride를 잘못 읽음)를 의심하십시오. 여기를 「정밀도 문제」로 처리해 버리면 고칠 수 있는 결함을 놓치게 됩니다.
flowchart TB
accTitle: 오차 허용과 진짜 결함의 경계
accDescr: 플러스마이너스 1에서 2의 차이는 표본화로 소수점 이하가 사라지는 것과 정수화 방침의 차이로 설명할 수 있고 이것으로 설명되지 않는 차이는 진짜 결함으로 변환의 전제를 의심해야 한다는 경계를 보여주는 그림.
dif1["기댓값과의 차이를 본다"] -->|"±1〜2의 차이"| exp1["표본화의 소수점과 정수화 방침으로 설명 가능"]
dif1 -->|"설명되지 않는 차이"| bug1["진짜 결함"]
bug1 --> prem1["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이 맞았다면 다음은 프레임 전체입니다. 같은 동영상의 같은 시각에서,
- 패턴 A(
MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING+RGB32) - 패턴 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의 혼동 |
| 전체가 투명 / 새까맣게 보인다 | 4바이트째를 0xFF로 채우지 않음. 7.1. |
차이의 「형태」를 보면 어디를 의심해야 할지 상당히 좁힐 수 있습니다. 전체가 일률적으로 어긋나 있다면 식이나 색 정보를, 국소적이라면 첨자나 stride를 의심합니다.
flowchart TB
accTitle: 검증의 2단계 구성
accDescr: 먼저 알려진 Y/U/V를 1화소만 넘겨 손 계산과 맞춰 보고 맞으면 같은 동영상의 같은 시각에서 패턴 A와 패턴 B의 출력 프레임을 맞춰 보고 차이의 형태로 어디를 의심할지 좁히는 2단계 검증을 보여주는 그림.
st5["1단계: 1화소를 손 계산과 맞춰 본다"] --> st6["2단계: 2가지 경로의 프레임을 맞춰 본다"]
st6 --> shp1["차이의 형태로 어디를 의심할지 좁힌다"]
st5 -.-> osf1["동영상도 Media Foundation도 필요 없다"]
그림 19: 1화소의 검증을 거친 뒤 프레임 전체를 비교하면, 차이의 원인을 형태로 좁힐 수 있다.
6. 어느 쪽을 고를 것인가
망설일 때는 다음 표로 상당 부분 정리할 수 있습니다.
| 관점 | 자동 변환 (MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING) |
직접 변환 |
|---|---|---|
| 구현 속도 | ◎ | △ |
| 몇 장의 정지 이미지 추출 | ◎ | ○ |
| 대량 frame / real-time | △ | ◎ |
| matrix / range를 명시적으로 제어하고 싶다 | △ | ◎ |
| GPU / D3D와 결합하고 싶다 | △ | ○〜◎ |
RGB32 이외의 출력을 원한다 |
△ | ◎ |
| 원리 이해 | ○ | ◎ |
첫 1개로는 이렇게 생각하면 편합니다.
- 일단 동작시키고 싶다 -> 자동 변환
- 색이나 성능의 책임을 지고 싶다 -> 직접 변환
실무에서는 「먼저 자동 변환으로 올바른 그림을 확인하고, 그 뒤 manual path로 바꾼다」는 순서도 꽤 유효합니다. 처음부터 전부를 짊어지면 어디서 그림이 망가졌는지 알기 어려워지기 때문입니다.
flowchart TB
accTitle: 실무에서 유효한 진행 순서
accDescr: 먼저 자동 변환으로 올바른 그림을 확인한 뒤 직접 만든 manual path로 바꾸면 어디서 그림이 망가졌는지 알기 쉬워진다는 실무의 진행 방식을 보여주는 그림.
step1["먼저 자동 변환으로 올바른 그림을 확인"] --> step2["그 뒤 manual path로 바꾼다"]
step2 --> good1["망가진 곳을 가려내기 쉽다"]
allin1["처음부터 전부를 짊어진다"] -.-> lost1["어디서 망가졌는지 알기 어렵다"]
그림 20: 정답이 되는 그림을 먼저 확보한 뒤 직접 구현으로 옮기면, 망가진 곳을 가려내기가 쉬워진다.
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가 반환하는 값을 우선합니다.
(pitch는 stride의 별명입니다. 3.2.에서 언급한 대로, 이 글에서는 같은 의미로 씁니다.)
7.4. color metadata를 보지 않고 601 / 709를 묵묵히 추측한다
색 사고는 눈에 잘 띄지 않습니다. 크래시도 하지 않습니다. 그래서 성가십니다.
MF_MT_YUV_MATRIXMF_MT_VIDEO_NOMINAL_RANGE
는 적어도 확인합시다. 그리고 자신의 코드가 대응하지 않는 값은 에러로 만든다 정도의 마음가짐이 딱 좋습니다.
7.5. NV12의 UV plane을 width * height로 잘라 버린다
plane offset은 실제 stride와 height로 정해집니다. width * height가 아닙니다.
여기를 대충 처리하면 색이 어긋나거나 이미지가 망가집니다.
flowchart TB
accTitle: NV12의 plane 경계를 구하는 법
accDescr: NV12의 UV plane 시작은 실제 stride와 height의 곱으로 정해지며 width와 height의 곱으로 자르면 색 어긋남이나 이미지 손상으로 이어짐을 보여주는 그림.
head1["버퍼 시작(Y plane)"] -->|"stride × height 나아간다"| uv2["UV plane의 시작"]
wr1["width × height로 자른다"] -.-> brk1["색 어긋남·이미지 손상"]
그림 21: UV plane의 경계는 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를 그대로 각 pixel에 쓰는 형태입니다. 용도에 따라서는 충분하지만, 화질을 우선한다면 YUV 권장 포맷 자료에 있는 upconversion의 사고방식까지 따라가 보는 편이 좋습니다.
8. 정리
Media Foundation에서 YUV를 RGB로 변환할 때는 먼저 다음 정리를 가지고 있으면 상당히 덜 헤맵니다.
- decoder 뒤에서는 RGB가 아니라
NV12나YUY2가 평범하게 나온다 - 편하게 가고 싶다면
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를 곱한다
는 이미지가 머릿속에 들어오면 꽤 자연스러워집니다. 우주색의 수수께끼 바이트열이 제대로 의미 있는 화소로 보이기 시작합니다.
flowchart TB
accTitle: 머릿속에 넣어 둘 이미지
accDescr: NV12는 2x2로 U/V를 공유하고 YUY2는 가로 2화소로 U/V를 공유하며 그 U/V와 Y에 matrix를 곱한다는 이미지가 머릿속에 들어오면 수수께끼의 바이트열이 의미 있는 화소로 보이기 시작함을 보여주는 그림.
im2["NV12: 2x2로 U/V를 공유"] --> ap1["그 U/V와 Y에 matrix를 곱한다"]
im3["YUY2: 가로 2화소로 U/V를 공유"] --> ap1
ap1 --> see1["바이트열이 의미 있는 화소로 보인다"]
그림 22: 공유의 단위와 matrix라는 2가지 이미지가 만들어지면, YUV의 바이트열은 자연스럽게 읽힌다.
9. 참고 자료
이 글의 샘플 코드
합동회사 고무라소프트 관련 글
Microsoft Learn
- Source Reader
- Using the Source Reader to Process Media Data
- MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING attribute
- IMFSourceReader::SetCurrentMediaType
- Recommended 8-Bit YUV Formats for Video Rendering
- Extended Color Information
- Uncompressed Video Buffers
- IMF2DBuffer::Lock2D
- MF_MT_VIDEO_NOMINAL_RANGE attribute
- MFVideoTransferMatrix enumeration
- Video Processor MFT
- Uncompressed RGB Video Subtypes
관련 기사
같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.
Media Foundation으로 MP4 동영상의 각 프레임에 이미지와 문자를 구워 넣는 방법 - Source Reader / 드로잉 / 색 변환 / Sink Writer 정리와 .cpp에 그대로 붙일 수 있는 1파일 완결판
MP4 동영상의 각 프레임에 이미지와 문자를 구워 넣어 새 MP4를 만드는 방법을, Source Reader 디코드 -> GDI+ 합성 -> NV12 색 변환 -> Sink Writer 재인코딩의 흐름과, Visual Studio C++에 그대로...
Media Foundation으로 MP4 동영상의 지정 시각에서 정지 이미지를 뽑는 방법 - .cpp에 그대로 붙일 수 있는 1파일 완결판
Media Foundation의 Source Reader로 MP4의 지정 시각에 가까운 프레임을 PNG로 꺼내는 흐름과, seek 어긋남·sample NULL·stride·RGB32 4바이트째의 함정을 정리하고, C++ 콘솔 앱용 1파일 완결 코...
Media Foundation이란 무엇인가 - COM과 Windows 미디어 API의 얼굴이 보이는 이유
Media Foundation이 무엇인지를 COM, HRESULT, IMFSourceReader, MFT 등 Windows 미디어 API의 기본 용어와 함께, 가장 먼저 짚어야 할 순서로 정리합니다.
Win32 스레드 풀 API ── CreateThreadpoolWork로 「스레드를 만들지 않는」병렬 처리
네이티브 코드에서 CreateThread를 여기저기 만들고 있지는 않은가요. Vista에서 개편된 Win32 스레드 풀 API의 work·timer·wait·io 네 객체, 클린업 그룹, 콜백에서 해서는 안 되는 일까지 1차 정보를 바탕으로 해설...
네임드 파이프의 실무 ── Windows 프로세스 간 통신의 정석을 설계부터 보안까지
Windows 프로세스 간 통신의 정석인 네임드 파이프를 실무 관점에서 정리합니다. 바이트/메시지 모드 선택, 여러 클라이언트를 받는 서버 설계, ACL과 위장 보안, .NET NamedPipeStream까지 1차 정보를 바탕으로 설명합니다.
관련 토픽
이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.
Windows 기술 토픽
Windows 개발, 장애 조사, 기존 자산 활용에 관한 KomuraSoft LLC 기사를 모은 토픽 허브입니다.
이 주제와 연결되는 서비스
이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.
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를 요구하는 방법입니다. 몇 장의 정지 이미지 추출이나 썸네일 생성이라면 이것이 가장 편합니다. 다만 이 자동 변환은 소프트웨어 처리이며 리얼타임 재생용으로 최적화되지 않았으므로, 대량 처리나 색 제어까지 원한다면 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를 공유합니다. 둘 다 실무에서 자주 나오는 포맷이며, 색을 솎아내는 방식(서브샘플링)이 다릅니다.