在 Media Foundation 中把 YUV 轉換成 RGB 的方法
· 更新日期: · Go Komura · Media Foundation, C++, Windows 開發, 影片處理, YUV
更新紀錄(2 筆,最後更新 2026年09月04日)
本文的修改紀錄。已保存的更新前版本,可透過附有 DOI 的永久連結閱讀。
引用本文(DOI: 10.5281/zenodo.21616283)
本文保存於 Zenodo。以下同時提供一律指向最新版本的 DOI,以及固定於您正在閱讀版本的 DOI。
Go Komura(2026)。〈在 Media Foundation 中把 YUV 轉換成 RGB 的方法〉。小村軟體有限公司。https://doi.org/10.5281/zenodo.21616283 https://comcomponent.com/zh-TW/blog/2026/03/15/002-media-foundation-yuv-to-rgb-conversion-patterns/
- DOI(最新版本)
- 10.5281/zenodo.21616283
- DOI(此版本)
- 10.5281/zenodo.22297124
想從影片裡抽出一個影格存成 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 的標頭檔與匯入用函式庫)。包含在 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,只有這兩條路。
圖中實線表示始終成立的關係,虛線表示附帶條件的關係(成立條件寫在詳細頁面中各關係的說明中)。關係的完整清單(共 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)是一列的位元組數。它不是影像的寬度本身,而是包含列尾 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 區塊的 4 個像素共用一組 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 個像素共用一組 U/V。Y0 與 Y1 各自獨立,但 U0 與 V0 是共用的。
因為是 packed,不需要計算 plane 邊界,但要移動到下一列,一樣得用 stride。
到這裡可以看出來,YUV -> RGB 不是一個 pixel 換一個 pixel 的單純替換。 首先要想清楚的是,被共用的 U/V 該怎麼分配給哪一個像素。
flowchart TB
accTitle: NV12 與 YUY2 的共用單位對照
accDescr: NV12 是 2x2 區塊的 4 個像素共用一組 U/V,YUY2 是橫向 2 個像素共用一組 U/V,因此必須先想清楚共用的 U/V 要分配給哪個像素的圖。
nv1["NV12(4:2:0)"] --> sh1["2x2 的 4 個像素共用 1 組 U/V"]
yy1["YUY2(4:2:2)"] --> sh2["橫向 2 個像素共用 1 組 U/V"]
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 裡,色彩空間資訊可以放在 media type 屬性上。至少要看下面這 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["clip 到 0..255"]
圖 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,就能查看實際的輸出尺寸與 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。如果要以 32bppBGRA 交給 WIC,把第 4 個位元組填成 0xFF 讓它不透明會比較安全。
flowchart TB
accTitle: RGB32 第 4 個位元組的處理
accDescr: Windows 的 RGB32 在 B、G、R 之後的第 4 個位元組是 alpha 還是 don't care 並未確定,所以在以 32bppBGRA 交給 WIC 之前,填成 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 或 YUY2 / limited range。
在這裡把前提縮小不是偷懶,反而很重要。YUV 轉換一旦做成「先全部接下來再說」的實作,就很容易無聲地把色彩弄壞。
flowchart TB
accTitle: 自行轉換的整體流程
accDescr: 把輸出設成 NV12 或 YUY2,查看實際的 media type 與屬性,lock buffer 之後求出各像素的 Y/U/V,再乘上 matrix 寫進 BGRA,說明自行轉換步驟的圖。
f1["要求 NV12 / YUY2"] --> f2["查看實際的 subtype 與屬性"]
f2 --> f3["lock buffer"]
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. 轉換之前,只接受自己支援的色彩資訊
自行轉換時,首先要從 media type 取得最低限度的資訊。
本文的範例只接受 NV12 / YUY2,而且 matrix 只放行 BT.601 或 BT.709、range 只放行 MFNominalRange_16_235。
#include <vector>
struct DecodedFrameInfo
{
GUID subtype = GUID_NULL;
UINT32 width = 0;
UINT32 height = 0;
LONG defaultStride = 0;
MFVideoTransferMatrix matrix = MFVideoTransferMatrix_Unknown;
MFNominalRange nominalRange = MFNominalRange_Unknown;
};
HRESULT GetDefaultStride(
IMFMediaType* pType,
LONG* plStride)
{
if (!pType || !plStride) return E_POINTER;
LONG stride = 0;
HRESULT hr = pType->GetUINT32(
MF_MT_DEFAULT_STRIDE,
reinterpret_cast<UINT32*>(&stride));
if (FAILED(hr))
{
GUID subtype = GUID_NULL;
UINT32 width = 0;
UINT32 height = 0;
hr = pType->GetGUID(MF_MT_SUBTYPE, &subtype);
if (FAILED(hr)) return hr;
hr = MFGetAttributeSize(pType, MF_MT_FRAME_SIZE, &width, &height);
if (FAILED(hr)) return hr;
hr = MFGetStrideForBitmapInfoHeader(subtype.Data1, width, &stride);
if (FAILED(hr)) return hr;
hr = pType->SetUINT32(MF_MT_DEFAULT_STRIDE, static_cast<UINT32>(stride));
if (FAILED(hr)) return hr;
}
*plStride = stride;
return S_OK;
}
HRESULT GetStrictDecodedFrameInfo(
IMFMediaType* pType,
DecodedFrameInfo* pInfo)
{
if (!pType || !pInfo) return E_POINTER;
HRESULT hr = pType->GetGUID(MF_MT_SUBTYPE, &pInfo->subtype);
if (FAILED(hr)) return hr;
if (pInfo->subtype != MFVideoFormat_NV12 &&
pInfo->subtype != MFVideoFormat_YUY2)
{
return MF_E_INVALIDMEDIATYPE;
}
hr = MFGetAttributeSize(pType, MF_MT_FRAME_SIZE, &pInfo->width, &pInfo->height);
if (FAILED(hr)) return hr;
hr = GetDefaultStride(pType, &pInfo->defaultStride);
if (FAILED(hr)) return hr;
UINT32 value = 0;
hr = pType->GetUINT32(MF_MT_YUV_MATRIX, &value);
if (FAILED(hr)) return hr;
pInfo->matrix = static_cast<MFVideoTransferMatrix>(value);
if (pInfo->matrix != MFVideoTransferMatrix_BT601 &&
pInfo->matrix != MFVideoTransferMatrix_BT709)
{
return MF_E_INVALIDMEDIATYPE;
}
hr = pType->GetUINT32(MF_MT_VIDEO_NOMINAL_RANGE, &value);
if (FAILED(hr)) return hr;
pInfo->nominalRange = static_cast<MFNominalRange>(value);
if (pInfo->nominalRange != MFNominalRange_16_235)
{
return MF_E_INVALIDMEDIATYPE;
}
return S_OK;
}
這裡是刻意做得 strict。
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」,而是「不知道」。把 709 套在 SD 解析度的素材上色彩會偏掉,反過來也一樣會偏。
在這個前提下,方針分成 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 時,直接使用 API 回傳的 stride(= pitch)比較安全。如果在這裡以 width 為基準硬算,之後就會無聲地壞掉。
flowchart TB
accTitle: stride 的優先順序
accDescr: MF_MT_DEFAULT_STRIDE 是最小的 stride,實際的 buffer 有可能帶著包含 padding 的 stride,因此若 Lock2D 可用就優先採用它的值,並避免以 width 為基準硬算的優先順序圖。
p1["Lock2D 回傳的 actual stride"] -->|"能用就最優先"| acc1["用於存取 buffer 的值"]
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 的係數
- 把結果 clip 到 0..255
- BGRA 的第 4 個位元組填成
255
5.7. 把 NV12 轉換成 BGRA32
NV12 是 4:2:0,所以 2x2 區塊的 4 個 pixel 共用同一組 U/V。
作為最小實作,把共用的 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 的兩種設計
accDescr: 最小實作是把共用 chroma 直接用在 4 個像素上的 nearest-neighbor 式解釋,多半已經夠實用,而追求最高畫質時,先從 4:2:0 經 4:2:2 再 upconversion 到 4:4:4 的設計在道理上比較漂亮的圖。
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 的位元組是以 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 階段:輸入已知的值,與手算結果核對
與其一開始就跑影片,把已知的 Y/U/V 傳給 ConvertLimitedYuvPixelToBgra 更確實。既不需要影片檔案,也不需要 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。
這裡的重點是 紅色是 254 而不是 255。原因不是係數的精度,而是輸入的 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,可能是用 nearest-neighbor 以外的方法做 chroma upsampling。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: 兩階段的驗證
accDescr: 先只傳入已知的 Y/U/V 一個像素與手算結果核對,對上之後再從同一支影片的同一時間點核對做法 A 與做法 B 的輸出影格,並從差異的形狀縮小懷疑範圍的兩階段驗證圖。
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 以外的輸出 |
△ | ◎ |
| 對原理的理解 | ○ | ◎ |
作為第一個版本,這樣想會比較輕鬆。
- 先讓它跑起來 -> 自動轉換
- 想扛起色彩與效能的責任 -> 自行轉換
實務上,「先用自動轉換確認正確的畫面,之後再換成 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,所以列與列之間的移動要用 actual stride,這是原則。
7.3. 把 MF_MT_DEFAULT_STRIDE 與 actual pitch 混為一談
MF_MT_DEFAULT_STRIDE 是「把那個格式以連續記憶體表示時的最小 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. 用 width * height 去切 NV12 的 UV plane
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. 用 progressive 的前提處理 interlaced video
本文的 manual 範例是以 progressive 為前提。如果把 interlaced 直接當成 1 個 field 讀取,有可能出現梳齒狀雜訊。 如果需要去交錯,把 Source Reader 的自動 video processing 或 Video Processor MFT 納入考慮會比較直接了當。
7.7. 忽略 4:2:0 的 chroma upsampling 品質
本文的 NV12 轉換為了好懂,採用把共用 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/VYUY2是橫向 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 這兩點的畫面,YUV 的位元組序列就讀得順了。
9. 參考資料
本文的範例程式碼
KomuraSoft 的相關文章
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 影格的方法
本文梳理如何用 Media Foundation 把圖片與文字烙印到 MP4 影片的每一個影格,再產出一支新的 MP4,並以 Source Reader、繪製、色彩轉換、Sink Writer 的分工,以及單一檔案完結的 C++ 範例來說明。
用 Media Foundation 從 MP4 的指定時間擷取靜態影像的方法
整理如何用 Source Reader 取出 MP4 中最接近指定時間的影格,把 stride 與 RGB32 的 alpha 整理好之後存成 PNG 的實作步驟。
Media Foundation 入門 - 從 COM 的視角理解這套 API
說明 Media Foundation 是什麼,並依照最初該掌握的順序,一併整理 COM、HRESULT、IMFSourceReader、MFT 等 Windows 媒體 API 的基本用語。
Time Travel Debugging ── 把長期運轉中無法重現的問題「錄下來」再倒回去
一個月才出現一次的問題,當機傾印只拍得到結果。本文說明如何用 WinDbg 的 Time Travel Debugging(TTD) 錄下執行並倒回,涵蓋 TTD.exe 的錄製設計、環形緩衝區、TTD.Calls 查詢,以及和傾印的分工。
引數為什麼會壞掉 ── Windows 命令列引數的規則
在 Windows 上並不存在引數的陣列,傳給 CreateProcess 的是一條字串,分割由接收端負責。本文說明 CommandLineToArgvW・CRT・.NET 的分割規則,以及 .NET 的 ArgumentList 與 C++ 中正確的組裝方式。
相關主題
與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。
Windows 技術主題
彙整 KomuraSoft LLC 關於 Windows 開發、故障調查與既有資產活用文章的主題中心。
與本主題相關的服務
本文連結到以下服務頁面,歡迎從最接近的入口查看。
Windows 應用程式開發
這是涵蓋 Media Foundation、Source Reader、影像儲存與影片影格轉換的 Windows 媒體處理實作主題,與 Windows 應用程式開發相當契合。
技術諮詢 & 設計審查
如果想先梳理 YUV / RGB 轉換的職責劃分、色彩空間、stride 與轉換路徑的設計,這個主題很適合以技術諮詢與設計審查的方式推進。
常見問題
整理諮詢這個主題時常見的問題。
- 為什麼 Media Foundation 的 decoder 輸出的是 YUV 而不是 RGB?
- 因為人眼對亮度細節的敏感度高於色彩細節,所以 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。兩者在實務上都很常見,差別在於色彩被抽掉的方式(色度次取樣)不同。