在 Media Foundation 中把 YUV 转换为 RGB 的方法

· 更新日期: · · Media Foundation, C++, Windows 开发, 视频处理, YUV

更新记录(2 条,最后更新 2026年09月03日)

本文的修改记录。已保存的更新前版本,可通过带有 DOI 的永久链接阅读。

本文此前是日文原文的节译,缺少大量章节、表格、Mermaid 图、图题、脚注与 FAQ。现已改写为日文原文的完整译文,技术主张与日文版一致,并补上了此前缺失的图与表,同时统一了全篇的术语译法。 查看更新前的版本 (DOI: 10.5281/zenodo.22276792)
补回了译文遗漏的 22 幅图中的 21 幅及其图注(图的结构与日文完全一致,只翻译了标签),并补充了日文原文中已有的咨询引导。 查看更新前的版本 (DOI: 10.5281/zenodo.21615465)
首次发布
引用本文(DOI: 10.5281/zenodo.21615464)

本文保存于 Zenodo。以下同时提供始终指向最新版本的 DOI,以及固定于您正在阅读版本的 DOI。

小村 豪(2026)。《在 Media Foundation 中把 YUV 转换为 RGB 的方法》。小村软件有限公司。https://doi.org/10.5281/zenodo.21615464 https://comcomponent.com/zh-CN/blog/2026/03/15/002-media-foundation-yuv-to-rgb-conversion-patterns/

DOI(最新版本)
10.5281/zenodo.21615464
DOI(此版本)
10.5281/zenodo.22281980

想从视频里抽出一帧存成 PNG、交给 WIC 或 GDI、又或者直接显示在界面上。在这些场景里,应用程序想要的都是 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 已经执行完毕。只有单个 pixel 的转换公式(5.6.)与操作系统无关,所以 GitHub 示例把它拆到了单独的头文件里,在 Linux 上用 g++ 也能测试。

1. 先说结论

先把结论摆出来,是这样的。

  • 只抽取几张静态图像或生成缩略图的话,启用 MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING 并请求 MFVideoFormat_RGB32 是最省事的
  • 不过这个自动转换是 软件处理,并未针对实时播放做优化
  • 如果要自己写转换,先把 NV12 和 YUY2 弄扎实 是最短的路径
  • YUV -> RGB 不是“乘上 3 个系数就结束”,实际上牵涉 色度子采样、range、matrix、stride
  • Media Foundation 的文档里广泛使用 YUV 这个词,但在数字视频里,把它当作实质上指的是 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 的视频链路里 YUV 系才是常态。

所以当应用程序想要 RGB 时,要在下面两者中选一个。

  1. 让 Media Foundation 一路转换到 RGB32
  2. 接收 YUV,用自己的代码转成 RGB

本文要讲的,正是这个分叉点。

3. 先理清 YUV 与 RGB 的关系

3.1. 嘴上说 YUV,实际讲的是 Y’CbCr

Windows 的 API 名称和文档广泛使用 YUV 这个词。不过在数字视频的语境下,把 U 读作 Cb、把 V 读作 Cr,基本没有问题。

粗略地说,关系是这样的:

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

人眼对亮度细节比对色彩细节更敏感。所以在视频里,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 block 中的 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)
  只有 1 个 plane (packed,所以没有边界)

在 YUY2 里,横向 2 个像素共用一组 U/V。Y0 和 Y1 各不相同,但 U0 和 V0 是共用的。 因为是 packed,所以不需要计算 plane 边界,但 行间的移动同样要用 stride。

到这里可以看出,YUV -> RGB 并不是简单的 1 个 pixel 对 1 个 pixel 的替换。 首先要考虑的是 把共用的 U/V 如何分配给哪个 pixel。

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 视频的 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 的视频中优先使用。

不过,在这里凭“分辨率大所以应该是 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 执行 limited 的 YUV -> RGB32 video processing 的功能。

不过正如 Microsoft Learn 中所写,这是 软件处理,并且 没有针对 playback 做优化。如果想每秒处理几百帧,靠它就不太对路了。

自动转换适合与不适合的场景说明 Source Reader 的自动转换是软件处理且没有针对 playback 优化,因此适合静态图像抽取、缩略图这类批处理用途,但不应在每秒几百帧的处理中依赖它。适合不要依赖Source Reader 的自动转换软件处理静态图像、缩略图、批处理每秒几百帧的实时处理

图8:自动转换是软件处理,所以只在少量帧的工具用途里使用。

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 个步骤说明启用自动转换的 4 个步骤:在 attributes 中启用视频处理后创建 Reader,选择视频 stream,请求 RGB32,再用 ReadSample 读取。在 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,就能确认实际输出的 size 与 stride。

4.4. 这个方法的强项

这个方法的好处,就是能 很快接近正确的画面。

  • 不用自己写 4:2:0 / 4:2:2 的展开
  • matrix / deinterlace 的麻烦被隐藏了大半
  • 方便交给 WIC 或 GDI
  • 处理几帧的话已经足够实用

做静态图像抽取类的工具时,先从这里入手是相当自然的。

4.5. 不过也有坑

这个自动转换具有下面这些性质。

项目 内容
转换目标 基本上是 RGB32
实现 软件处理
适合的用途 少量 frame、缩略图、离线处理
不适合的用途 基于 D3D 的 real-time rendering、大批量 frame 处理
不兼容的属性 MF_SOURCE_READER_D3D_MANAGER、MF_READWRITE_DISABLE_CONVERTERS

另外还有一点很重要,就是 RGB32 第 4 个 byte 的处理。 Windows 的 RGB32 在内存中的排列是 Blue / Green / Red / Alpha or Don’t Care,并不是 ARGB32。如果要以 32bppBGRA 的形式交给 WIC,把第 4 个 byte 填成 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 的枚举文档里确实有“把 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 定死了”

无论选哪一种,唯独不能默默一刀切。 色偏不会导致崩溃,会在没被察觉的情况下上生产。

matrix 为 Unknown 时的分支说明 Unknown 的含义不是已知它是 BT.709 而是不知道,方针分成 strict 地报错挡掉,或者用默认值放行但把假设记进日志这两种,唯独不能默默一刀切。本文的方针如果必须放行唯独要避免这个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,实际的缓冲区有时带有包含 padding 的 stride,因此能用 Lock2D 时优先用它返回的值,并避免按 width 定死。能用就最优先fallback会悄无声息地坏掉Lock2D 返回的 actual stride用于访问 buffer 的值MF_MT_DEFAULT_STRIDE(最小值)按 width 定死

图15:行间移动用的 stride 最优先取 Lock2D 的实测值,不按 width 定死。

5.6. 把单个 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 个 byte 设为 255

5.7. 把 NV12 转换成 BGRA32

NV12 是 4:2:0,所以 2x2 block 中的 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,纵向 2 行共用 1 行 UV -> 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 共用一组 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 的 pipeline 内部,还有别的设计。

  • 编写自己的 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;六位小数的舍入影响出现在小数点后第 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 的示例中,这个单 pixel 转换被拆到了与操作系统无关的头文件里,所以没有 Windows 也能跑这个测试。

第 2 段:对照模式 A 与模式 B 的输出

单个 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 个 byte 没有填成 0xFF。参见 7.1.

只要看差异的“形状”,就能把该怀疑的地方缩得相当小。整体均匀偏移就是公式或色彩信息的问题,局部出问题就是下标或 stride 的问题。

两段式验证说明两段式验证:先只传入已知的 Y/U/V 做单像素手算对照,对上之后再从同一视频的同一时刻对照模式 A 与模式 B 的输出帧,并从差异的形状缩小怀疑范围。第 1 段:单像素与手算对照第 2 段:对照两条路径的帧从差异的形状缩小怀疑范围不需要视频,也不需要 Media Foundation

图19:先通过单像素的验证,再比较整帧,就能靠差异的形状锁定原因。

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 个 byte 有可能是 0,导致图像透明。保存前填入 0xFF 会更安全。

7.2. 用 width * bytesPerPixel 把 stride 定死

这是相当常见的问题。 实际的 sample buffer 有可能带 padding,所以 行间的移动使用 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_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. 把 interlaced 视频按 progressive 来处理

本文的手动示例以 progressive 为前提。如果把 interlaced 直接当成 1 个 field 来读,可能会出现梳状噪点。 如果需要 deinterlace,把 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 的解码器为什么输出 YUV 而不是 RGB?
因为人眼对亮度细节比对色彩细节更敏感,所以视频采用让 Y(偏亮度的成分)保留精细、U/V(色差成分)适当粗化的设计更有效。因此在 Windows 的视频链路中,decoder 输出的未压缩帧通常是 NV12 或 YUY2 这类 YUV 系格式。另外,在数字视频的语境下,把 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。两者都是实务中常见的格式,区别在于色彩的抽稀方式(子采样)不同。

作者简介

本文作者的个人简介页面。

Go Komura

小村软件有限公司 代表

以 Windows 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。

返回博客列表