在 Media Foundation 中把 YUV 轉換成 RGB 的方法

· 更新日期: · · Media Foundation, C++, Windows 開發, 影片處理, YUV

更新紀錄(2 筆,最後更新 2026年09月04日)

本文的修改紀錄。已保存的更新前版本,可透過附有 DOI 的永久連結閱讀。

已將繁體中文版改寫為日文原文的完整翻譯。先前的繁體中文版只譯出日文原文的一部分,遺漏了章節、表格、Mermaid 圖、圖說與 FAQ。本次依日文原文將這些內容全部補回,並新增本文的知識地圖章節。技術主張與日文版一致。 查看更新前的版本 (DOI: 10.5281/zenodo.22279101)
補回了譯文遺漏的 22 幅圖中的 21 幅及其圖說(圖的結構與日文完全一致,只翻譯了標籤),並補上了日文原文中已有的諮詢引導。 查看更新前的版本 (DOI: 10.5281/zenodo.21616284)
初次發布
引用本文(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.lib
  • mfreadwrite.lib
  • mfuuid.lib
  • ole32.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 自行轉換。就是這兩選一。

本文的兩選一想省事就讓 Source Reader 輸出 RGB32,想要大量處理與掌握色彩就直接接住 YUV 自行轉換,說明本文所處理的兩選一的圖。想省事大量處理、掌握色彩想要 RGB 的影格做法 A:讓 Source Reader 輸出 RGB32做法 B:接住 YUV 自行轉換

圖 1: 想省事就走做法 A,願意扛下處理量與色彩責任就走做法 B,只有這兩條路。

圖中實線表示始終成立的關係,虛線表示附帶條件的關係(成立條件寫在詳細頁面中各關係的說明中)。關係的完整清單(共 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 的語境裡,把 U 讀成 Cb、把 V 讀成 Cr,幾乎不會出問題。

粗略地說,關係是這樣。

  • Y 是偏亮度的成分
  • U / V 是色差成分
  • RGB 則是每個像素直接持有 Red / Green / Blue

人眼對亮度細節的敏感度高於色彩細節。所以在 video 裡,把 Y 保留得細、U/V 保留得粗一點的設計會很有效。這就是 YUV 系格式經常被採用的理由。

YUV 系被採用的理由人眼對亮度細節的敏感度高於色彩細節,因此把 Y 保留得細、U/V 保留得粗的設計很有效,這就是 YUV 系格式常被採用的理由的圖。人眼對亮度敏感把 Y 保留得細、U/V 保留得粗YUV 系格式被採用的理由

圖 3: 配合人眼對亮度敏感的特性,把 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 該怎麼分配給哪一個像素。

NV12 與 YUY2 的共用單位對照NV12 是 2x2 區塊的 4 個像素共用一組 U/V,YUY2 是橫向 2 個像素共用一組 U/V,因此必須先想清楚共用的 U/V 要分配給哪個像素的圖。NV12(4:2:0)2x2 的 4 個像素共用 1 組 U/VYUY2(4:2:2)橫向 2 個像素共用 1 組 U/V決定要分配給哪個像素、怎麼分配

圖 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 層來掌握就好理解。

  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 裡,色彩空間資訊可以放在 media type 屬性上。至少要看下面這 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(中心)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 最佳化。如果想每秒處理好幾百張,靠它就有點不對了。

自動轉換適合與不適合的用途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. 程式碼

下面的程式碼假設 CoInitializeEx 與 MFStartup 都已經呼叫完畢。最小構成大致是這樣。

#include <windows.h>
#include <mfapi.h>
#include <mfidl.h>
#include <mfreadwrite.h>
#include <mferror.h>
#include <wrl/client.h>

#pragma comment(lib, "mfplat.lib")
#pragma comment(lib, "mfreadwrite.lib")
#pragma comment(lib, "mfuuid.lib")
#pragma comment(lib, "ole32.lib")

using Microsoft::WRL::ComPtr;

HRESULT CreateSourceReaderWithAutoRgb(
    const wchar_t* path,
    IMFSourceReader** ppReader)
{
    if (!path || !ppReader) return E_POINTER;
    *ppReader = nullptr;

    ComPtr<IMFAttributes> attrs;
    HRESULT hr = MFCreateAttributes(&attrs, 2);
    if (FAILED(hr)) return hr;

    hr = attrs->SetUINT32(MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING, TRUE);
    if (FAILED(hr)) return hr;

    hr = MFCreateSourceReaderFromURL(path, attrs.Get(), ppReader);
    if (FAILED(hr)) return hr;

    hr = (*ppReader)->SetStreamSelection(MF_SOURCE_READER_ALL_STREAMS, FALSE);
    if (FAILED(hr)) return hr;

    hr = (*ppReader)->SetStreamSelection(MF_SOURCE_READER_FIRST_VIDEO_STREAM, TRUE);
    if (FAILED(hr)) return hr;

    ComPtr<IMFMediaType> outType;
    hr = MFCreateMediaType(&outType);
    if (FAILED(hr)) return hr;

    hr = outType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Video);
    if (FAILED(hr)) return hr;

    hr = outType->SetGUID(MF_MT_SUBTYPE, MFVideoFormat_RGB32);
    if (FAILED(hr)) return hr;

    hr = (*ppReader)->SetCurrentMediaType(
        MF_SOURCE_READER_FIRST_VIDEO_STREAM,
        nullptr,
        outType.Get());
    if (FAILED(hr)) return hr;

    return S_OK;
}

HRESULT ReadOneRgb32Sample(
    IMFSourceReader* reader,
    IMFSample** ppSample,
    LONGLONG* pTimestamp100ns)
{
    if (!reader || !ppSample) return E_POINTER;
    *ppSample = nullptr;
    if (pTimestamp100ns) *pTimestamp100ns = 0;

    DWORD streamIndex = 0;
    DWORD flags = 0;
    LONGLONG timestamp = 0;

    HRESULT hr = reader->ReadSample(
        MF_SOURCE_READER_FIRST_VIDEO_STREAM,
        0,
        &streamIndex,
        &flags,
        &timestamp,
        ppSample);

    if (FAILED(hr)) return hr;
    if (flags & MF_SOURCE_READERF_ENDOFSTREAM) return MF_E_END_OF_STREAM;
    if (*ppSample == nullptr) return MF_E_INVALID_STREAM_DATA;

    if (pTimestamp100ns) *pTimestamp100ns = timestamp;
    return S_OK;
}

之後呼叫 GetCurrentMediaType,就能查看實際的輸出尺寸與 stride。

4.4. 這個做法的強項

這個做法的好處,就是能很快接近正確的畫面。

  • 不必自己寫 4:2:0 / 4:2:2 的展開
  • 能把 matrix / deinterlace 的麻煩藏起來一大半
  • 容易交給 WIC 或 GDI
  • 只處理幾個影格的話,實用度已經足夠

在靜態影像擷取這類工具裡,先從這裡入手相當自然。

4.5. 但也有陷阱

這個自動轉換有下列性質。

項目 內容
轉換目標 基本上是 RGB32
實作 software 處理
適合的用途 少量 frame、縮圖、離線處理
不適合的用途 以 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 讓它不透明會比較安全。

RGB32 第 4 個位元組的處理Windows 的 RGB32 在 B、G、R 之後的第 4 個位元組是 alpha 還是 don't care 並未確定,所以在以 32bppBGRA 交給 WIC 之前,填成 0xFF 讓它不透明比較安全的圖。填成 0xFF直接交出去RGB32 的記憶體排列B、G、R 這 3 個位元組第 4 個位元組是 alpha 或 don't care可以當成 32bppBGRA 交給 WIC有時候會變成透明

圖 10: 第 4 個位元組並未確定,所以先填成 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 的輸出設成 NV12 或 YUY2
  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 或 YUY2 / limited range。 在這裡把前提縮小不是偷懶,反而很重要。YUV 轉換一旦做成「先全部接下來再說」的實作,就很容易無聲地把色彩弄壞。

自行轉換的整體流程把輸出設成 NV12 或 YUY2,查看實際的 media type 與屬性,lock buffer 之後求出各像素的 Y/U/V,再乘上 matrix 寫進 BGRA,說明自行轉換步驟的圖。要求 NV12 / YUY2查看實際的 subtype 與屬性lock buffer求出各像素的 Y/U/V乘上 matrix 寫進 BGRA把前提縮小就不容易弄壞色彩

圖 12: 自行轉換依照要求、查看、lock、參照、轉換的順序推進,前提縮得越小越安全。

5.3. 先明確指定輸出的 media type

首先,告訴 Source Reader「請直接把 YUV 輸出給我」。這裡一樣假設 CoInitializeEx / MFStartup 已經完成。

#include <windows.h>
#include <mfapi.h>
#include <mfidl.h>
#include <mfreadwrite.h>
#include <mferror.h>
#include <wrl/client.h>

using Microsoft::WRL::ComPtr;

HRESULT ConfigureSourceReaderForSubtype(
    IMFSourceReader* reader,
    REFGUID subtype)
{
    if (!reader) return E_POINTER;

    HRESULT hr = reader->SetStreamSelection(MF_SOURCE_READER_ALL_STREAMS, FALSE);
    if (FAILED(hr)) return hr;

    hr = reader->SetStreamSelection(MF_SOURCE_READER_FIRST_VIDEO_STREAM, TRUE);
    if (FAILED(hr)) return hr;

    ComPtr<IMFMediaType> outType;
    hr = MFCreateMediaType(&outType);
    if (FAILED(hr)) return hr;

    hr = outType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Video);
    if (FAILED(hr)) return hr;

    hr = outType->SetGUID(MF_MT_SUBTYPE, subtype);
    if (FAILED(hr)) return hr;

    hr = reader->SetCurrentMediaType(
        MF_SOURCE_READER_FIRST_VIDEO_STREAM,
        nullptr,
        outType.Get());
    if (FAILED(hr)) return hr;

    return S_OK;
}

這裡的 subtype 要傳入 MFVideoFormat_NV12 或 MFVideoFormat_YUY2。

要注意的是,要求的 subtype 不一定會照原樣通過。實際輸出的是什麼,要用 GetCurrentMediaType 查看。

把要求與實際輸出分開看用 SetCurrentMediaType 要求的 subtype 不一定會照原樣通過,因此實際輸出什麼要用 GetCurrentMediaType 查看的流程圖。不一定照原樣通過要求 subtype實際的輸出用 GetCurrentMediaType 查看用查到的值來寫後續處理

圖 13: 要求歸要求,實際輸出一定要先用 GetCurrentMediaType 確認過再拿來用。

5.4. 轉換之前,只接受自己支援的色彩資訊

自行轉換時,首先要從 media type 取得最低限度的資訊。 本文的範例只接受 NV12 / YUY2,而且 matrix 只放行 BT.601 或 BT.709、range 只放行 MFNominalRange_16_235。

#include <vector>

struct DecodedFrameInfo
{
    GUID subtype = GUID_NULL;
    UINT32 width = 0;
    UINT32 height = 0;
    LONG defaultStride = 0;
    MFVideoTransferMatrix matrix = MFVideoTransferMatrix_Unknown;
    MFNominalRange nominalRange = MFNominalRange_Unknown;
};

HRESULT GetDefaultStride(
    IMFMediaType* pType,
    LONG* plStride)
{
    if (!pType || !plStride) return E_POINTER;

    LONG stride = 0;
    HRESULT hr = pType->GetUINT32(
        MF_MT_DEFAULT_STRIDE,
        reinterpret_cast<UINT32*>(&stride));

    if (FAILED(hr))
    {
        GUID subtype = GUID_NULL;
        UINT32 width = 0;
        UINT32 height = 0;

        hr = pType->GetGUID(MF_MT_SUBTYPE, &subtype);
        if (FAILED(hr)) return hr;

        hr = MFGetAttributeSize(pType, MF_MT_FRAME_SIZE, &width, &height);
        if (FAILED(hr)) return hr;

        hr = MFGetStrideForBitmapInfoHeader(subtype.Data1, width, &stride);
        if (FAILED(hr)) return hr;

        hr = pType->SetUINT32(MF_MT_DEFAULT_STRIDE, static_cast<UINT32>(stride));
        if (FAILED(hr)) return hr;
    }

    *plStride = stride;
    return S_OK;
}

HRESULT GetStrictDecodedFrameInfo(
    IMFMediaType* pType,
    DecodedFrameInfo* pInfo)
{
    if (!pType || !pInfo) return E_POINTER;

    HRESULT hr = pType->GetGUID(MF_MT_SUBTYPE, &pInfo->subtype);
    if (FAILED(hr)) return hr;

    if (pInfo->subtype != MFVideoFormat_NV12 &&
        pInfo->subtype != MFVideoFormat_YUY2)
    {
        return MF_E_INVALIDMEDIATYPE;
    }

    hr = MFGetAttributeSize(pType, MF_MT_FRAME_SIZE, &pInfo->width, &pInfo->height);
    if (FAILED(hr)) return hr;

    hr = GetDefaultStride(pType, &pInfo->defaultStride);
    if (FAILED(hr)) return hr;

    UINT32 value = 0;

    hr = pType->GetUINT32(MF_MT_YUV_MATRIX, &value);
    if (FAILED(hr)) return hr;

    pInfo->matrix = static_cast<MFVideoTransferMatrix>(value);
    if (pInfo->matrix != MFVideoTransferMatrix_BT601 &&
        pInfo->matrix != MFVideoTransferMatrix_BT709)
    {
        return MF_E_INVALIDMEDIATYPE;
    }

    hr = pType->GetUINT32(MF_MT_VIDEO_NOMINAL_RANGE, &value);
    if (FAILED(hr)) return hr;

    pInfo->nominalRange = static_cast<MFNominalRange>(value);
    if (pInfo->nominalRange != MFNominalRange_16_235)
    {
        return MF_E_INVALIDMEDIATYPE;
    }

    return S_OK;
}

這裡是刻意做得 strict。 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」

無論選哪一種,唯一要避免的就是默默地四捨五入。色彩偏移不會當掉,所以會在沒被察覺的狀態下就上線維運。

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 時,直接使用 API 回傳的 stride(= pitch)比較安全。如果在這裡以 width 為基準硬算,之後就會無聲地壞掉。

stride 的優先順序MF_MT_DEFAULT_STRIDE 是最小的 stride,實際的 buffer 有可能帶著包含 padding 的 stride,因此若 Lock2D 可用就優先採用它的值,並避免以 width 為基準硬算的優先順序圖。能用就最優先fallback無聲地壞掉Lock2D 回傳的 actual stride用於存取 buffer 的值MF_MT_DEFAULT_STRIDE(最小值)以 width 為基準硬算

圖 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 的設計在道理上比較漂亮。

chroma upsampling 的兩種設計最小實作是把共用 chroma 直接用在 4 個像素上的 nearest-neighbor 式解釋,多半已經夠實用,而追求最高畫質時,先從 4:2:0 經 4:2:2 再 upconversion 到 4:4:4 的設計在道理上比較漂亮的圖。最小實作:共用 chroma 直接用外觀上多半已經夠實用先 upconversion 再轉換道理上漂亮、畫質優先4:2:0 → 4:2:2 → 4:4:4 的順序

圖 16: 把共用 chroma 直接拿來用的最小實作,與逐階段 upconversion 的設計,兩者依情況取捨。

5.8. 把 YUY2 轉換成 BGRA32

YUY2 是 packed 的 4:2:2。 因為只是 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
  • 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 階段:輸入已知的值,與手算結果核對

與其一開始就跑影片,把已知的 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 讀錯)。如果把這裡當成「精度問題」草草了事,就會漏掉本來修得好的缺陷。

容許差異與真正缺陷的分界正負 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,可能是用 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。

兩階段的驗證先只傳入已知的 Y/U/V 一個像素與手算結果核對,對上之後再從同一支影片的同一時間點核對做法 A 與做法 B 的輸出影格,並從差異的形狀縮小懷疑範圍的兩階段驗證圖。第 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,有時候第 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_MATRIX
  • MF_MT_VIDEO_NOMINAL_RANGE

這兩個至少要看一下。 然後抱著自己的程式碼不支援的值就當成錯誤這種心態,力道剛剛好。

7.5. 用 width * height 去切 NV12 的 UV plane

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. 用 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/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 乘上 matrixYUY2:橫向 2 個像素共用 U/V位元組序列看得出是有意義的像素

圖 22: 只要建立共用單位與 matrix 這兩點的畫面,YUV 的位元組序列就讀得順了。

9. 參考資料

本文的範例程式碼

KomuraSoft 的相關文章

Microsoft Learn

共用相同標籤的最新文章。能以相近的主題延伸理解。

與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。

本文連結到以下服務頁面,歡迎從最接近的入口查看。

常見問題

整理諮詢這個主題時常見的問題。

為什麼 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。兩者在實務上都很常見,差別在於色彩被抽掉的方式(色度次取樣)不同。

作者檔案

本文作者的個人檔案頁面。

Go Komura

小村軟體有限公司 代表

以 Windows 軟體開發、技術諮詢與故障調查為中心,在難以重現的故障調查與既有資產仍在運作的專案上具有優勢。

回到部落格一覽