在 Media Foundation 中把 YUV 转换为 RGB 的方法
· 更新日期: · 小村 豪 · Media Foundation, C++, Windows 开发, 视频处理, YUV
更新记录(2 条,最后更新 2026年09月03日)
本文的修改记录。已保存的更新前版本,可通过带有 DOI 的永久链接阅读。
引用本文(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.libmfreadwrite.libmfuuid.libole32.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 自己转换。就这两条路。
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 的视频链路里 YUV 系才是常态。
所以当应用程序想要 RGB 时,要在下面两者中选一个。
- 让 Media Foundation 一路转换到 RGB32
- 接收 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 系格式被广泛使用的原因。
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 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。
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 视频的 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 的视频中优先使用。
不过,在这里凭“分辨率大所以应该是 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 执行 limited 的 YUV -> RGB32 video processing 的功能。
不过正如 Microsoft Learn 中所写,这是 软件处理,并且 没有针对 playback 做优化。如果想每秒处理几百帧,靠它就不太对路了。
flowchart TB
accTitle: 自动转换适合与不适合的场景
accDescr: 说明 Source Reader 的自动转换是软件处理且没有针对 playback 优化,因此适合静态图像抽取、缩略图这类批处理用途,但不应在每秒几百帧的处理中依赖它。
auto1["Source Reader 的自动转换"] --> sw1["软件处理"]
sw1 -->|"适合"| bat1["静态图像、缩略图、批处理"]
sw1 -.->|"不要依赖"| rt1["每秒几百帧的实时处理"]
图8:自动转换是软件处理,所以只在少量帧的工具用途里使用。
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: 说明启用自动转换的 4 个步骤:在 attributes 中启用视频处理后创建 Reader,选择视频 stream,请求 RGB32,再用 ReadSample 读取。
a1["在 attributes 中启用视频处理"] --> a2["选择视频 stream"]
a2 --> a3["请求 RGB32"]
a3 --> a4["用 ReadSample 读取"]
a4 -.-> a5["在 decoder 后面被转换成 RGB32"]
图9:只要走完这 4 步,接在 decoder 后面的 video processing 就会一路做到 RGB32。
4.3. 代码
下面的代码假设 CoInitializeEx 与 MFStartup 已经执行完毕。最小实现大致是这个样子。
#include <windows.h>
#include <mfapi.h>
#include <mfidl.h>
#include <mfreadwrite.h>
#include <mferror.h>
#include <wrl/client.h>
#pragma comment(lib, "mfplat.lib")
#pragma comment(lib, "mfreadwrite.lib")
#pragma comment(lib, "mfuuid.lib")
#pragma comment(lib, "ole32.lib")
using Microsoft::WRL::ComPtr;
HRESULT CreateSourceReaderWithAutoRgb(
const wchar_t* path,
IMFSourceReader** ppReader)
{
if (!path || !ppReader) return E_POINTER;
*ppReader = nullptr;
ComPtr<IMFAttributes> attrs;
HRESULT hr = MFCreateAttributes(&attrs, 2);
if (FAILED(hr)) return hr;
hr = attrs->SetUINT32(MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING, TRUE);
if (FAILED(hr)) return hr;
hr = MFCreateSourceReaderFromURL(path, attrs.Get(), ppReader);
if (FAILED(hr)) return hr;
hr = (*ppReader)->SetStreamSelection(MF_SOURCE_READER_ALL_STREAMS, FALSE);
if (FAILED(hr)) return hr;
hr = (*ppReader)->SetStreamSelection(MF_SOURCE_READER_FIRST_VIDEO_STREAM, TRUE);
if (FAILED(hr)) return hr;
ComPtr<IMFMediaType> outType;
hr = MFCreateMediaType(&outType);
if (FAILED(hr)) return hr;
hr = outType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Video);
if (FAILED(hr)) return hr;
hr = outType->SetGUID(MF_MT_SUBTYPE, MFVideoFormat_RGB32);
if (FAILED(hr)) return hr;
hr = (*ppReader)->SetCurrentMediaType(
MF_SOURCE_READER_FIRST_VIDEO_STREAM,
nullptr,
outType.Get());
if (FAILED(hr)) return hr;
return S_OK;
}
HRESULT ReadOneRgb32Sample(
IMFSourceReader* reader,
IMFSample** ppSample,
LONGLONG* pTimestamp100ns)
{
if (!reader || !ppSample) return E_POINTER;
*ppSample = nullptr;
if (pTimestamp100ns) *pTimestamp100ns = 0;
DWORD streamIndex = 0;
DWORD flags = 0;
LONGLONG timestamp = 0;
HRESULT hr = reader->ReadSample(
MF_SOURCE_READER_FIRST_VIDEO_STREAM,
0,
&streamIndex,
&flags,
×tamp,
ppSample);
if (FAILED(hr)) return hr;
if (flags & MF_SOURCE_READERF_ENDOFSTREAM) return MF_E_END_OF_STREAM;
if (*ppSample == nullptr) return MF_E_INVALID_STREAM_DATA;
if (pTimestamp100ns) *pTimestamp100ns = timestamp;
return S_OK;
}
之后调用 GetCurrentMediaType,就能确认实际输出的 size 与 stride。
4.4. 这个方法的强项
这个方法的好处,就是能 很快接近正确的画面。
- 不用自己写 4:2:0 / 4:2:2 的展开
- matrix / deinterlace 的麻烦被隐藏了大半
- 方便交给 WIC 或 GDI
- 处理几帧的话已经足够实用
做静态图像抽取类的工具时,先从这里入手是相当自然的。
4.5. 不过也有坑
这个自动转换具有下面这些性质。
| 项目 | 内容 |
|---|---|
| 转换目标 | 基本上是 RGB32 |
| 实现 | 软件处理 |
| 适合的用途 | 少量 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 使其不透明 会更安全。
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 的枚举文档里确实有“把 Unknown 当作 BT.709 处理”这类说法,但在实务中如果默默地这样一刀切,色偏就会变得很难察觉。至少在最初的实现里,把不支持的组合直接判为错误 更安全。
Unknown 会在什么情况下返回
你可能觉得“是不是太严了”,所以这里列出 Unknown 会出现的路径。大体上都是 “原始视频本身没有携带色彩信息” 的情况。
- H.264 / HEVC 的 VUI 里没有写入色彩信息。按照规范,当
colour_description_present_flag为 0 时,matrix_coefficients会被当作“未指定”。在缺少这个信息的状态下经过 decoder,传给下游的 matrix 同样是未指定 - 来自采集设备或旧容器的裸 YUV。这是一条不携带色彩空间描述的路径
- 也有
MF_MT_YUV_MATRIX属性本身就不存在 的情况。这时GetUINT32不会返回值,而是以MF_E_ATTRIBUTENOTFOUND失败(上面的代码用FAILED(hr)直接把它挡掉了)
这里重要的是,Unknown 的含义不是“已知它是 BT.709”,而是“不知道”。给 SD 分辨率的素材套用 709 会导致色偏,反过来也会偏。
在此之上,方针分成 2 种。
- strict 地挡掉(本文的方针):作为不支持的情况返回错误,让上层判断“这个素材不支持”。与其让颜色悄悄跑偏,不如干脆说明处理不了,这样更安全
- 定一个默认值放行:如果无论如何都必须放行,就在遇到
Unknown时把自己做了什么假设 记进日志。并在其中写明“按分辨率把 601 / 709 定死了”
无论选哪一种,唯独不能默默一刀切。 色偏不会导致崩溃,会在没被察觉的情况下上生产。
flowchart TB
accTitle: matrix 为 Unknown 时的分支
accDescr: 说明 Unknown 的含义不是已知它是 BT.709 而是不知道,方针分成 strict 地报错挡掉,或者用默认值放行但把假设记进日志这两种,唯独不能默默一刀切。
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,实际的缓冲区有时带有包含 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. 把单个 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 的设计在道理上更干净。
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 共用一组 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 的 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 读错)。如果把这里当成“精度问题”糊弄过去,就会漏掉本可以修好的缺陷。
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 的示例中,这个单 pixel 转换被拆到了与操作系统无关的头文件里,所以没有 Windows 也能跑这个测试。
第 2 段:对照模式 A 与模式 B 的输出
单个 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 个 byte 没有填成 0xFF。参见 7.1. |
只要看差异的“形状”,就能把该怀疑的地方缩得相当小。整体均匀偏移就是公式或色彩信息的问题,局部出问题就是下标或 stride 的问题。
flowchart TB
accTitle: 两段式验证
accDescr: 说明两段式验证:先只传入已知的 Y/U/V 做单像素手算对照,对上之后再从同一视频的同一时刻对照模式 A 与模式 B 的输出帧,并从差异的形状缩小怀疑范围。
st5["第 1 段:单像素与手算对照"] --> st6["第 2 段:对照两条路径的帧"]
st6 --> shp1["从差异的形状缩小怀疑范围"]
st5 -.-> osf1["不需要视频,也不需要 Media Foundation"]
图19:先通过单像素的验证,再比较整帧,就能靠差异的形状锁定原因。
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 个 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_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. 把 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/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 的第 4 个字节(alpha)之后保存为 PNG 的完整实现步骤。
Time Travel Debugging ── 把长期运行中不复现的缺陷“录下来”再倒回去
一个月才出一次的缺陷,崩溃转储只拍得到结果。本文讲解如何用 WinDbg 的 Time Travel Debugging(TTD) 录制执行并倒回,涵盖 TTD.exe 的录制设计、环形缓冲区、TTD.Calls 查询,以及与转储的分工。
参数为什么会坏掉 ── Windows 命令行参数的规则
在 Windows 上并不存在参数的数组,传给 CreateProcess 的是一条字符串,切分由接收方完成。本文讲解 CommandLineToArgvW・CRT・.NET 的切分规则,以及 .NET 的 ArgumentList 与 C++ 中正确的组装方法。
父进程消失之后还剩下什么 —— 用 Job Object 圈养子进程
为什么强制结束 UI 之后,SDK 的辅助进程仍然残留,一直占着摄像头或 COM 端口?本文从测量应用的视角,讲解如何用 Job Object 把进程树变成一个单位,并借助 KillOnJobClose 与完成端口来设计子进程的寿命。
相关主题
与本文相近的主题页面。以本文为起点,可以进一步了解相关服务和其他文章。
Windows 技术主题
汇整 KomuraSoft LLC 关于 Windows 开发、故障调查与既有资产活用文章的主题中心。
与本主题相关的服务
本文与以下服务页面相关联,欢迎从最接近的入口查看。
Windows 应用程序开发
这是包含 Media Foundation、Source Reader、图像保存、视频帧转换在内的 Windows 媒体处理实现主题,与 Windows 应用开发的契合度很高。
技术咨询 & 设计评审
如果希望先梳理清楚 YUV / RGB 转换的职责划分、色彩空间、stride 以及转换路径的设计,这个主题很适合以技术咨询、设计评审的形式推进。
常见问题
汇总了咨询这一主题时常见的问题。
- 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。两者都是实务中常见的格式,区别在于色彩的抽稀方式(子采样)不同。