一、先说一个不会报错的 bug
你做了一条视频管线。采集或者录屏拿到 CMSampleBuffer,取出 CVPixelBuffer,丢进 Metal 做一层处理,再写回 buffer,交给 AVAssetWriter 出文件。
跑通了。文件能播,时长对,音画同步,没有一行警告。
然后有人跟你说:"录出来的没有屏幕上好看。"
你问他哪不好看,他说不上来。"就是......有点发灰?没那么通透。"
你自己录一段,截屏和录屏的文件放一起对比。确实。黑的地方不够黑,白的地方不够白,整个画面像蒙了一层。但你单看录屏文件,又完全说得过去------你甚至怀疑是不是自己的错觉,或者屏幕亮度。
这个 bug 有几个特征,它们几乎定义了一整类问题:
- 没有任何一层失败。
CVPixelBufferCreate返回kCVReturnSuccess,append返回true,编码器不丢帧,文件的moov完全合法,用任何播放器打开都不报错。 - 它错在每一帧、每一个像素上 ,但每个像素只错一点点。所以它跨过了你所有的防线------
assert是为"要么对要么崩"设计的,这个 bug 既不对也不崩。 - 靠记忆对比不出来。 必须把两张图放在一起,逐像素取色,才能确认"确实偏了"。
- 它有两个方向,一个可以救回来,另一个已经不可逆了。
- 修好之后你会发现,同一个根因在管线里还藏着另外三处。
这篇写的就是这一类。YUV→RGB 的颜色范围(range)和矩阵(matrix)不匹配,是移动端音视频里最典型的"不报错、只让画质变差"的 bug 家族。它不难懂,但它极难被发现------难被发现的原因本身,才是这篇文章里最值钱的部分。
先给一张对号入座表:
| 现象 | 大概率根因 |
|---|---|
| 全画面发灰,黑不够黑白不够白,对比度像被压过 | video range 数据被当成 full range 解释 |
| 暗部细节整块糊死,高光死白,且不可恢复 | full range 数据被当成 video range 解释 |
| 灰阶和文字完全正常,只有高饱和度的色块偏 | 矩阵用错(601 ↔ 709) |
| 同一份代码,1080p 正常,480p 偏色 | 管线默认矩阵是按分辨率猜的 |
| 画面整体发白发灰,但取色发现是非线性的偏移 | 多做了一次 sRGB gamma 编码 |
| 只在接了第三方 SDK 之后偏 | 别人给你的 buffer 没带 attachment |
二、把账算死:发灰到底灰多少
先把最典型的那个错误量化出来。这个算术是整篇的锚点。
kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange(fourcc '420v')里,8-bit 分量的取值不是 0~255:
css
Y' ∈ [16, 235] 跨度 219
Cb/Cr ∈ [16, 240] 跨度 224,中点 128
而 ...FullRange('420f')是:
css
Y' ∈ [0, 255] 跨度 255
Cb/Cr ∈ [0, 255] 中点 128
注意一件事:Y 的跨度是 219,色度的跨度是 224,两个分母不一样。 这是后面写 shader 时最容易写错的一处,先记着。
方向 A:video range 数据被当成 full range 解
数据里的黑是 16,你直接拿去当亮度用:
ini
黑:16 / 255 = 6.27% ------ 本该是 0
白:235 / 255 = 92.16% ------ 本该是 100%
动态范围被压到 (235 - 16) / 255 = 85.9%,对比度损失 14.1%,而且整体抬了一个 6.27% 的黑电平。
"抬黑电平 + 压对比度"------这在观感上的名字就叫发灰。它不是色偏,是通透感消失。用户描述不出来,因为人眼对绝对亮度极不敏感,对相对对比度才敏感;你把整幅画一起压,他只觉得"闷"。
关键是:这是一个仿射变换。 out = in × 0.859 + 0.063。信息一点没丢,你事后拉一条曲线就能完全还原。
方向 B:full range 数据被当成 video range 解
反过来。数据是 0~255 的,你按 video range 去做拉伸:
scss
Y' = (Y - 16) × 255 / 219
代进去:
ini
Y = 0 → -18.6 → clip 到 0
Y = 8 → -9.3 → clip 到 0
Y = 15 → -1.2 → clip 到 0
Y = 240 → 260.3 → clip 到 255
Y = 255 → 278.2 → clip 到 255
[0, 15] 这 16 级全部被压成 0,[236, 255] 这 20 级全部被压成 255。
暗部的层次直接销毁。夜景、黑色背景上的深灰文字、渐变的暗端------全部糊成一块死黑,而且不可逆,因为 clip 是多对一映射,你事后拉曲线只会把一块纯黑拉成一块纯灰。

由此得到第一条工程规则
这两个方向的严重性不对称。
- 方向 A(发灰):仿射,可逆,观感差但信息在。
- 方向 B(压死):clip,不可逆,信息销毁。
所以,当你拿到一个不知道 range 的 buffer、必须赌一把的时候,赌 full range。 宁可发灰,不要压死。发灰的片子后期能救,压死的救不回来。
当然更好的做法是别赌------在管线入口把它探出来,这个后面讲。
三、矩阵错了,为什么你的测试永远是绿的
range 是"数值怎么缩放",矩阵是"三个分量怎么组合"。两者独立,可以各错各的。
常见的三套:
| 标准 | Kr | Kb | 典型使用场景 |
|---|---|---|---|
| BT.601 | 0.299 | 0.114 | SD(≤576 行)、老素材、部分摄像头默认 |
| BT.709 | 0.2126 | 0.0722 | HD(720p/1080p),移动端绝大多数场景 |
| BT.2020 | 0.2627 | 0.0593 | UHD / HDR |
解码公式(Cb、Cr 已归一化到 [-0.5, 0.5]):
python
R' = Y' + 2(1 - Kr)·Cr
B' = Y' + 2(1 - Kb)·Cb
G' = Y' - [2Kr(1-Kr)/Kg]·Cr - [2Kb(1-Kb)/Kg]·Cb 其中 Kg = 1 - Kr - Kb
现在做一件事:把 Cb 和 Cr 设成 0(也就是中性灰)。
python
R' = Y'
G' = Y'
B' = Y'
跟 Kr、Kb 完全无关。
这就是这篇文章里我认为最值钱的一条观察:
BT.601 和 BT.709 在整条灰阶上是逐比特等价的。
推论非常残酷:
- 你的黑白测试图,测不出矩阵错误。
- 你的灰阶渐变,测不出矩阵错误。
- 你录一段纯文字 UI、设置页、聊天界面------测不出矩阵错误。
- 你写一百条断言,只要喂进去的是低饱和度画面,全部通过。
而 UI 录屏恰恰就是大面积白底黑字。你的开发自测、QA 的冒烟用例,用的都是 App 自己的界面。这个 bug 会安安静静地穿过所有测试,直到有个用户去录了一段游戏或者一条短视频。
那有饱和度的时候错多少
把误差量化。取 BT.709 编码的数据,用 BT.601 去解(这是最常见的错配方向,因为 709 是移动端主流,而不少手写 shader 抄的是网上流传最广的 601 系数)。
纯绿 (0, 255, 0):
709 编码得到 Y'=0.7152, Cb=-0.3855, Cr=-0.4542。用 601 解:
python
R' = 0.7152 + 1.402 × (-0.4542) = 0.0784 → 20
G' = 0.7152 + 0.714×0.4542 + 0.344×0.3855 = 1.172 → clip 255
B' = 0.7152 + 1.772 × (-0.3855) = 0.0321 → 8
得到 (20, 255, 8),还溢出被 clip 了。绿色发脏、掉饱和。
反方向(601 数据按 709 解)纯绿:
python
R' → clip 0
G' → 0.845 → 215
B' → clip 0
(0, 215, 0):绿色暗了 16%。
典型肤色 (224, 172, 140):
709 编码后用 601 解,得到 (219, 169, 142)。最大偏差 5/255 ≈ 2%。
基本看不出来。
所以矩阵错误的可见度大致是这样一条曲线(都取 709 数据按 601 解,最大分量偏差):
scss
中性灰 (128,128,128) → (128,128,128) 偏差 0 (0.0%) ← 你的 UI 录屏在这里
肤色 (224,172,140) → (219,169,142) 偏差 5 (2.0%) ← 你的自测视频在这里
品牌橙 (255,149, 0) → (245,148, 7) 偏差 10 (3.9%)
品牌蓝 ( 0,122,255) → ( 12,126,248) 偏差 12 (4.7%)
纯绿 ( 0,255, 0) → ( 20,255, 8) 偏差 20 (7.8%) ← 用户的游戏录屏在这里
纯红 (255, 0, 0) → (233, 0, 2) 偏差 22 (8.6%)
反方向(601 数据按 709 解)更狠,纯绿会掉到 (0, 215, 0),偏差 40/255 = 15.7%------因为这个方向不发生 clip,误差能完整地表达出来。

由此第二条工程规则:
一个 bug 的可发现性由你的输入决定,不由你的断言决定。 测颜色管线,测试素材必须含高饱和色。SMPTE 彩条不是仪式感,它是唯一能让矩阵错误露出来的输入。
四、按踩坑顺序:九个具体的地方
坑 1:fourcc 只描述"怎么存",不描述"怎么解"
kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange 这个名字里带 VideoRange,很容易让人以为它把解释规则一起带上了。
它没有。它只说明了字节的取值区间和平面布局。矩阵(601/709/2020)根本不在 fourcc 里。
真正携带"怎么解"的,是 CVBuffer 上的三个 attachment:
swift
kCVImageBufferYCbCrMatrixKey // .ITU_R_709_2 / .ITU_R_601_4 / .ITU_R_2020
kCVImageBufferColorPrimariesKey // 色域:709 / P3_D65 / 2020
kCVImageBufferTransferFunctionKey // 传递函数:709 / sRGB / PQ / HLG
fourcc 是契约的一半,attachment 是另一半。只有两半都在,一个 buffer 才是自描述的。
顺带一提,CMSampleBuffer 层面还有第二份副本,藏在 CMVideoFormatDescription 的 extensions 里:
swift
kCMFormatDescriptionExtension_YCbCrMatrix
kCMFormatDescriptionExtension_ColorPrimaries
kCMFormatDescriptionExtension_TransferFunction
两处可能不一致。你手动改了 pixel buffer 的 attachment,但 format description 是上游给的没动,下游读哪个取决于它的实现。这种"同一个事实存了两份"的结构本身就是 bug 温床------改一处就要同步另一处,或者干脆重建 format description。
坑 2:手工创建的 CVPixelBuffer 是"裸"的
这是最高频的一处。你在 Metal 里处理完,需要一个新的输出 buffer:
swift
// 反面教材
var out: CVPixelBuffer?
CVPixelBufferPoolCreatePixelBuffer(nil, pool, &out)
// ... 渲染到 out ...
writerInput.append(makeSampleBuffer(out!, pts))
out 上的 attachments 是空的。你把 attachment 丢了。
下游拿到一个没有 attachment 的 buffer,不会报错------它会猜。猜的规则见坑 3。
正确做法是把源 buffer 的 attachment 搬过去:
swift
// CVBufferPropagateAttachments 只搬"可传播"的那些(colorimetry 三件套在内),
// 不会把 source 的 timing 之类的东西一起带过来。
CVBufferPropagateAttachments(src, dst)
或者,当你明确知道自己产出的是什么,就显式写死,别依赖传播:
swift
func stampColorimetry(_ pb: CVPixelBuffer,
matrix: CFString = kCVImageBufferYCbCrMatrix_ITU_R_709_2,
primaries: CFString = kCVImageBufferColorPrimaries_ITU_R_709_2,
transfer: CFString = kCVImageBufferTransferFunction_ITU_R_709_2) {
CVBufferSetAttachment(pb, kCVImageBufferYCbCrMatrixKey,
matrix, .shouldPropagate)
CVBufferSetAttachment(pb, kCVImageBufferColorPrimariesKey,
primaries, .shouldPropagate)
CVBufferSetAttachment(pb, kCVImageBufferTransferFunctionKey,
transfer, .shouldPropagate)
}
注意 .shouldPropagate------用 .shouldNotPropagate 打的标记,下一次 CVBufferPropagateAttachments 不会带走它,等于埋了一颗延迟生效的雷。
跨进程的场景要格外小心。 Broadcast Extension 把 buffer 传给主 App、走 XPC、走共享内存、走第三方 SDK 的回调------只要中间发生过一次"重新构造一个 buffer 再拷数据",attachment 就断了。而这个断点在代码上完全无痕,因为它就是几行成功返回的 CVPixelBufferCreate。

坑 3:默认矩阵是按分辨率猜的------所以分辨率会改变颜色
当 attachment 缺失时,系统按一条历史约定填默认值:
arduino
高度 ≤ 576(或者说 SD 尺寸) → BT.601
HD(720p / 1080p) → BT.709
UHD / HDR → BT.2020
这条约定本身是合理的------它来自广播电视时代 SD 用 601、HD 用 709 的事实。问题在于它带来的后果:
同一份代码,录 1080p 颜色正常,录 480p 就偏色。
而且这个 bug 出现的位置极其刁钻:低分辨率通常出现在降级路径上------低端机降码率降分辨率、弱网降清晰度、省电模式。而降级路径恰恰是测试覆盖最薄的地方,没人会在 iPhone 15 Pro 上专门去测 480p。
没有任何人会预期"改一个分辨率参数会改变颜色"。这两件事在心智模型里毫无关系。但在这条约定下,它们是耦合的。
解法只有一个:不要让任何人猜。 管线里每一个产出 buffer 的点,都把 colorimetry 显式打上。
坑 4:AVAssetWriter 的输出也要显式声明
写文件的时候同理。不填 AVVideoColorPropertiesKey,AVAssetWriter 就按分辨率猜,然后把猜的结果写进 mp4 的 colr box / HEVC 的 VUI 里。播放器读到什么就按什么解。
swift
let settings: [String: Any] = [
AVVideoCodecKey: AVVideoCodecType.h264,
AVVideoWidthKey: width,
AVVideoHeightKey: height,
AVVideoColorPropertiesKey: [
AVVideoColorPrimariesKey: AVVideoColorPrimaries_ITU_R_709_2,
AVVideoTransferFunctionKey: AVVideoTransferFunction_ITU_R_709_2,
AVVideoYCbCrMatrixKey: AVVideoYCbCrMatrix_ITU_R_709_2,
],
AVVideoCompressionPropertiesKey: [
AVVideoAverageBitRateKey: bitrate,
AVVideoProfileLevelKey: AVVideoProfileLevelH264HighAutoLevel,
],
]
但填错比不填更糟。 这里声明的必须和你实际送进去的像素数据一致。如果你的数据是 601 编的,你在这里写 709,等于让每一个播放器都按错的规则解------原来只有你的 App 显示不对,现在全世界显示都不对,而且文件本身"合法",没人会怀疑它。
坑 5:双重转换------你转了一次,别人又转了一次
你手写 shader 把 YUV 转成了 BGRA,然后为了"保险"调了 CVBufferPropagateAttachments,于是这个 BGRA buffer 上带着 YCbCrMatrix = 709。
对一个已经是 RGB 的 buffer 说"它的 YCbCr 矩阵是 709"是没有意义的。多数系统实现会忽略,但不是所有------跨库、跨端、自己写的那一层,谁都可能读它。
更常见的双重转换来自 Core Image:
swift
// 你以为它只是把像素搬过去
ciContext.render(ciImage, to: outputPixelBuffer)
CIContext 默认是做色彩管理 的。它有 workingColorSpace(默认线性 sRGB)和 outputColorSpace。render 会做一次色彩空间转换。如果你在前面已经手工处理过一次,这里就是第二次。
如果你要的是"纯粹的像素搬运,别动我的颜色":
swift
let ctx = CIContext(mtlDevice: device, options: [
.workingColorSpace: NSNull(), // 关掉色彩管理
.outputColorSpace: NSNull(),
.cacheIntermediates: false,
])
这里的反直觉在于:一个看起来"什么都没做"的 API,其实做了事。 render 这个名字暗示的是搬运,实际语义是"按色彩管理规则转换并搬运"。凡是默认开启色彩管理的 API,都要么全用它、要么全不用它,最怕的是一半自己算一半交给它。
坑 6:R'G'B' 的那一撇,不是排版洁癖
这是"发灰"的另一个真凶,而且和 range 完全无关。
BT.601/709 的转换矩阵,作用对象是已经做过 gamma 编码的 R'G'B'(读作 R-prime),不是线性光的 RGB。你从 YUV 解出来的东西,直接就是 sRGB 空间里的 R'G'B' 值,不需要再做任何 gamma 处理。
现在看这行:
swift
// 反面教材
let desc = MTLTextureDescriptor.texture2DDescriptor(
pixelFormat: .bgra8Unorm_srgb, // ← 注意这个 _srgb
width: w, height: h, mipmapped: false)
_srgb 格式的语义是:GPU 在写入时自动把线性值编码成 sRGB,在采样时自动解码回线性。 你的 shader 输出的已经是编码过的 R'G'B',GPU 又给你编了一次。
编两次是什么效果?拿 50% 中灰算:
ini
sRGB 编码值 128/255 = 0.502
再编一次:1.055 × 0.502^(1/2.4) - 0.055 = 0.737 → 188
128 变成了 188。 画面整体发白、发灰,中间调被大幅抬起。
反过来,如果多解码了一次:
ini
((0.502 + 0.055) / 1.055)^2.4 = 0.216 → 55
128 变成了 55。 画面发暗、发闷,暗部沉下去。
这两个数字非常有用,后面的诊断表会用到------中灰变成 188 还是 55,一眼定位是多编了还是多解了。
规则:手写 YUV→RGB 的 shader,输出纹理用 .bgra8Unorm,不要 _srgb。除非你在 shader 里显式把 R'G'B' 线性化了(那你就是真的在线性空间工作,另当别论)。
坑 7:shader 里那三个容易写错的常量
把前面的东西写成代码。用 CVMetalTextureCache 从双平面 buffer 取两张纹理:Y 平面用 .r8Unorm,CbCr 平面用 .rg8Unorm。采样出来已经是归一化到 [0, 1] 的浮点,不要再除 255。
metal
#include <metal_stdlib>
using namespace metal;
struct ColorConfig {
float3x3 matrix; // 列主序:R/G/B 各自的 (Y, Cb, Cr) 系数
float yScale; // video: 255/219 full: 1.0
float cScale; // video: 255/224 full: 1.0
float yOffset; // video: 16/255 full: 0.0
};
fragment float4 yuv2rgb(VertexOut in [[stage_in]],
texture2d<float> yTex [[texture(0)]],
texture2d<float> cbcrTex[[texture(1)]],
constant ColorConfig& cfg [[buffer(0)]])
{
constexpr sampler s(filter::linear, address::clamp_to_edge);
// 1) Y 减 16/255 后按 219 拉伸;色度减 0.5(offset binary)后按 224 拉伸。
// 注意两者分母不同:219 vs 224。这是最容易写错的一处。
float y = (yTex.sample(s, in.uv).r - cfg.yOffset) * cfg.yScale;
float2 cbcr = (cbcrTex.sample(s, in.uv).rg - 0.5) * cfg.cScale;
float3 ycc = float3(y, cbcr.x, cbcr.y);
float3 rgb = cfg.matrix * ycc;
// 2) 越界必须 clamp。矩阵运算 + 色度插值会产生合法 YUV 映射不到的 RGB,
// 不 clamp 在某些格式下会 wrap 成鬼影色块。
return float4(saturate(rgb), 1.0);
}
CPU 侧的系数表:
swift
enum YCbCrMatrix {
case bt601, bt709, bt2020
/// 返回 (Kr, Kb)
var coefficients: (Float, Float) {
switch self {
case .bt601: return (0.299, 0.114)
case .bt709: return (0.2126, 0.0722)
case .bt2020: return (0.2627, 0.0593)
}
}
/// R = Y + 0·Cb + a·Cr
/// G = Y - c·Cb - b·Cr
/// B = Y + d·Cb + 0·Cr
var matrix3x3: simd_float3x3 {
let (kr, kb) = coefficients
let kg = 1 - kr - kb
let a = 2 * (1 - kr) // 601: 1.4020 709: 1.5748 2020: 1.4746
let d = 2 * (1 - kb) // 601: 1.7720 709: 1.8556 2020: 1.8814
let b = 2 * kr * (1 - kr) / kg // 601: 0.7141 709: 0.4681 2020: 0.5713
let c = 2 * kb * (1 - kb) / kg // 601: 0.3441 709: 0.1873 2020: 0.1646
// simd_float3x3(columns:) 是列主序,每一列是一个 (R,G,B) 分量的系数向量
return simd_float3x3(columns: (
SIMD3<Float>( 1, 1, 1), // Y 的系数
SIMD3<Float>( 0, -c, d), // Cb 的系数
SIMD3<Float>( a, -b, 0) // Cr 的系数
))
}
}
struct ColorConfig {
var matrix: simd_float3x3
var yScale: Float
var cScale: Float
var yOffset: Float
init(matrix m: YCbCrMatrix, fullRange: Bool) {
self.matrix = m.matrix3x3
self.yScale = fullRange ? 1.0 : 255.0 / 219.0
self.cScale = fullRange ? 1.0 : 255.0 / 224.0
self.yOffset = fullRange ? 0.0 : 16.0 / 255.0
}
}
最关键的一点:这个 ColorConfig 不能写死,必须从 buffer 上读。
swift
func colorConfig(for pb: CVPixelBuffer) -> ColorConfig {
// range 来自 fourcc
let fourcc = CVPixelBufferGetPixelFormatType(pb)
let isFull = (fourcc == kCVPixelFormatType_420YpCbCr8BiPlanarFullRange)
|| (fourcc == kCVPixelFormatType_420YpCbCr8PlanarFullRange)
// 矩阵来自 attachment
let raw = CVBufferGetAttachment(pb, kCVImageBufferYCbCrMatrixKey, nil)?
.takeUnretainedValue() as? NSString
let matrix: YCbCrMatrix
switch raw {
case kCVImageBufferYCbCrMatrix_ITU_R_709_2 as NSString: matrix = .bt709
case kCVImageBufferYCbCrMatrix_ITU_R_601_4 as NSString: matrix = .bt601
case kCVImageBufferYCbCrMatrix_ITU_R_2020 as NSString: matrix = .bt2020
default:
// 没带 attachment。这里是唯一允许"猜"的地方,
// 而且必须留下痕迹------静默猜测是这类 bug 的温床。
assertionFailure("pixel buffer 缺少 YCbCrMatrix attachment")
Telemetry.log(.missingColorimetry, size: CVPixelBufferGetHeight(pb))
matrix = CVPixelBufferGetHeight(pb) > 576 ? .bt709 : .bt601
}
return ColorConfig(matrix: matrix, fullRange: isFull)
}
这段代码的价值不在于它怎么转,在于它的 default 分支:把"猜"这件事变成一个有断言、有埋点的显式行为,而不是散落在系统各层里的静默默认值。你至少要知道自己在哪些帧上猜过。
坑 8:CIImage 直接吃 pixel buffer 的坑
swift
let ci = CIImage(cvPixelBuffer: pb)
Core Image 会读 attachment 来决定怎么解。没 attachment 就回到坑 3。而且它还会读 kCVImageBufferTransferFunctionKey 决定要不要做线性化------所以传递函数标错,Core Image 出来的结果会是非线性的偏移,比单纯的 range 错更难分析。
如果你确定要覆盖,CIImage 有初始化选项可以指定色彩空间;但更干净的做法还是在上游把 attachment 打对,让所有下游读同一份事实。
坑 9:截图路径(CGImage → CVPixelBuffer)
合成水印、封面、UI 元素的时候常见。CGContext 画出来的是 sRGB 的 R'G'B',是 full range 。你把它塞进一个标着 '420v' 的 buffer,或者塞进一条默认按 video range 处理的管线,就是方向 B------暗部直接压死。
处理静态图层的时候要明确:它进管线的哪一段?如果管线在这一点上是 full range RGB,直接用;如果这一点已经是 video range YUV,你得自己做一次 range 压缩再编码,不能只做矩阵变换。
五、把它变成能自动检测的东西
前面所有的坑,靠 code review 都防不住------因为出错的代码全都长得像对的。这类 bug 只能靠端到端的逐点比对来防。
关键在于设计一张测试图案。它要同时探到 range、matrix、gamma 三件事,每一件都要有一个结果唯一的探针。
css
┌────────────────────────────────────────────┐
│ A. SMPTE 彩条(高饱和) → 探矩阵 │
├────────────────────────────────────────────┤
│ B. Y = 0 / 8 / 16 / 128 / 235 / 240 / 255 │
│ 七级台阶 → 探 range │
├────────────────────────────────────────────┤
│ C. 50% 中灰大色块 → 探 gamma │
└────────────────────────────────────────────┘
把它编码成 YUV 送进管线,从管线末端取出 RGB,逐块取平均值,对照下表:
| 探针 | 期望 | 实测 | 结论 |
|---|---|---|---|
B: Y=16 黑块 |
RGB 0 | 16 | video range 数据被当 full range → 发灰 |
B: Y=235 白块 |
RGB 255 | 235 | 同上 |
B: Y=8 |
RGB 8 | 0 | full range 数据被当 video range → 暗部压死 |
B: Y=240 |
RGB 240 | 255 | 同上 |
| C: 中灰 128 | 128 | 188 | 多做了一次 sRGB 编码(_srgb 纹理格式) |
| C: 中灰 128 | 128 | 55 | 多做了一次 sRGB 解码 |
| A: 纯绿 | (0,255,0) | (20,255,8) | 709 数据按 601 解 |
| A: 纯绿 | (0,255,0) | (0,215,0) | 601 数据按 709 解 |
| A 偏 / B 全对 | --- | --- | 只有矩阵错,range 是对的 |
| A、B 都偏,且偏移是线性的 | --- | --- | range 错,矩阵未必错 |
Y=16 和 Y=235 是这张图里最重要的两个 sentinel。它们是 video range 的边界值,是唯一能把两种 range 区分开的地方------中灰 128 在两种解释下只差 2/255,你测中灰是测不出 range 的,必须测端点。
落成一个测试:
swift
func testColorPipelinePreservesLevels() throws {
let probe = try ColorProbePattern.make( // 上面那张图,编码为 420v + 709 attachment
range: .video, matrix: .bt709)
let out = try pipeline.process(probe.pixelBuffer) // 走完整条管线
let rgb = try RGBSampler(out)
// range 端点
XCTAssertEqual(rgb.mean(at: probe.rect(.black16)).r, 0, accuracy: 2)
XCTAssertEqual(rgb.mean(at: probe.rect(.white235)).r, 255, accuracy: 2)
// gamma
XCTAssertEqual(rgb.mean(at: probe.rect(.midGray)).r, 128, accuracy: 3)
// 矩阵:唯一能暴露 601/709 错配的断言
let green = rgb.mean(at: probe.rect(.pureGreen))
XCTAssertEqual(green.r, 0, accuracy: 4)
XCTAssertEqual(green.g, 255, accuracy: 4)
XCTAssertEqual(green.b, 0, accuracy: 4)
}
再加两条不需要跑图案的廉价防线,成本几乎为零:
swift
// 1) 管线每个产出 buffer 的节点,debug 下断言 attachment 齐全
#if DEBUG
func assertColorimetryPresent(_ pb: CVPixelBuffer, _ stage: StaticString) {
let has = CVBufferGetAttachment(pb, kCVImageBufferYCbCrMatrixKey, nil) != nil
assert(has, "\(stage): colorimetry attachment 丢失")
}
#endif
// 2) release 下埋点。你要知道线上有多少帧是在"猜"里跑的,
// 以及它们集中在哪些机型 / 哪些分辨率上。
第 2 条尤其值:坑 3 那个"480p 才偏色"的问题,靠埋点能在用户投诉之前就看出来------分辨率维度上的缺失率分布会直接把它指出来。
六、诚实地说,多数人不该自己写这个
上面写了这么多,现在说反话。
如果你只是要显示、要录制、不做像素级处理,一行 shader 都别写。
- 预览:
AVCaptureVideoPreviewLayer或AVSampleBufferDisplayLayer - 播放:
AVPlayerLayer - 简单滤镜:
CIFilter+CIContext全托管 - 录制:
AVAssetWriter直接 append 上游给的CMSampleBuffer,别拆开
这些路径里,attachment 从采集一路带到编码,系统各层都正确读它。你不碰,就不会错。这篇文章里的九个坑,一个都遇不到。
你必须自己管的,是这五种情况:
- 自己写 Metal / OpenGL 渲染管线------你从 buffer 里取纹理的那一刻,就接管了解释权。
- 跨进程传 buffer------Broadcast Extension 到主 App、XPC、共享内存。中间只要重建过 buffer,attachment 就断了。
- 接第三方 SDK 给的 buffer------美颜、AI 模型、云厂商 SDK。它们输出的 buffer 带不带 attachment、带的对不对,你必须自己验,不能假设。
- 自己写 muxer 或推流 ------
colrbox、HEVC 的 VUI、SPS 里的video_full_range_flag和matrix_coefficients,都得你自己填。 - 需要跨端一致------iOS 编、Android 或 Web 播。两端的默认猜测规则不一定一样,不显式声明就是在赌。
如果你不在这五种里,用系统方案,别学我。
七、退到更高一层看
把这篇里的东西压缩一下,剩下的其实不是颜色问题。
第一,我们所有的工程防御,都是为"离散失败"设计的。
assert、异常、Result 类型、崩溃监控、健康检查、错误码------这一整套武器库的前提是:系统要么工作、要么不工作。它们能抓住"崩了"、"返回了 nil"、"超时了"。
而 range/matrix 错配是连续退化 :每一帧都错,每一帧都只错一点,管线全程 success。它不触发任何一个断言,因为它从没进入"失败"状态。
连续退化需要另一套武器:
| 离散失败 | 连续退化 |
|---|---|
| assert / 异常 | 基线 + 逐点比对(golden test) |
| 错误码 | 端点探针(sentinel 值) |
| 崩溃率 | 分布式埋点看缺失率 和分布 |
| "跑通了吗" | "跑出来的和上次一样吗" |
如果你的系统里有任何一段是"错了也不会崩"的------图像、音频、排版、数值计算、机器学习------那你的 CI 里必须有 golden test。没有它,你对这段代码的质量是零观测。
第二,"让别人猜"是最大的 bug 源头。
attachment 丢失本质上不是技术问题,是契约问题。你把"这是什么"扔了,只留下"这是多少"。剩下的每一层都只能猜,而每一层的猜法可能不同。
这个模式换个皮,你到处都见过:
- 字符编码:一串字节没带 charset → 乱码。同构。
- 时区:一个 timestamp 没带 tz → 差 8 小时。同构。
- 单位:一个数字没带单位 → 1999 年火星气候探测者号,磅力秒 vs 牛顿秒,2.6 亿刀烧了。同构。
- 浮点:一个数没带精度约定 → 金额算错。同构。
全是同一个 bug:数据和它的解释规则分开存放,然后解释规则在某个中转站被丢掉了。 而丢掉的那一刻,永远是静默的------因为丢的是元数据,不是数据,代码看不出少了什么。
对应的工程规则也就一条:任何跨越边界(进程、模块、文件、网络)的数据,必须是自描述的。 边界上多带几个字节的元数据,比在下游写一百个默认值分支便宜得多。
第三,默认值是技术债的一种,而且是不计息但会突然到期的那种。
"没填就按分辨率猜 601/709"------这个默认值在广播电视时代是完全正确的工程决策,它让老代码不用改就能跑。但它把一个耦合藏了起来:分辨率 → 颜色。二十年后,一个在 iPhone 上写降级逻辑的人改了个数字,颜色就变了,而他连这两件事有关系都不知道。
好的默认值让你跑得快,坏的默认值让你不知道自己在赌。 区别在于:它有没有在你赌的时候告诉你一声。
回到标题。画面发灰的真凶,从来不是那几个矩阵系数------那是公开的、抄一遍就对的东西。真凶是:你在某个地方把"这些像素该怎么解释"这条信息弄丢了,然后管线里的每一层都替你做了一个它自己认为合理的假设。
颜色不是像素值。颜色是像素值加上一套解释规则。丢了后者,前者一文不值。
如果你正在排查这类问题,可以直接跑一下第五节那张探针图,把 Y=16、Y=235、中灰 128 三个块的输出值贴出来------三个数字基本上就能定位到上面九个坑里的哪一个。
关于本文代码
文中的 Swift 与 Metal 示例用于说明思路和 API 调用形态,未逐行编译验证 ,直接复制到工程里需要补全上下文(顶点着色器与 VertexOut 定义、CVMetalTextureCache 的创建与生命周期管理、CVBufferGetAttachment 的 Unmanaged 桥接细节、simd_float3x3 的列主序约定与你的矩阵约定是否一致、错误处理与线程约束等)。
文中的数值均由 BT.601 / BT.709 / BT.2020 的公开系数与 sRGB 传递函数直接计算得出,可自行复算。涉及的系统行为(CVBufferPropagateAttachments 的传播范围、CIContext 的默认色彩管理、缺失 attachment 时按分辨率选择默认矩阵的约定)以公开文档与实际调试观察为准,不同 iOS 版本可能存在差异,请以你手上的设备实测为准。