YUV→RGB 的颜色范围与矩阵:画面发灰的真凶

一、先说一个不会报错的 bug

你做了一条视频管线。采集或者录屏拿到 CMSampleBuffer,取出 CVPixelBuffer,丢进 Metal 做一层处理,再写回 buffer,交给 AVAssetWriter 出文件。

跑通了。文件能播,时长对,音画同步,没有一行警告。

然后有人跟你说:"录出来的没有屏幕上好看。"

你问他哪不好看,他说不上来。"就是......有点发灰?没那么通透。"

你自己录一段,截屏和录屏的文件放一起对比。确实。黑的地方不够黑,白的地方不够白,整个画面像蒙了一层。但你单看录屏文件,又完全说得过去------你甚至怀疑是不是自己的错觉,或者屏幕亮度。

这个 bug 有几个特征,它们几乎定义了一整类问题:

  1. 没有任何一层失败。 CVPixelBufferCreate 返回 kCVReturnSuccessappend 返回 true,编码器不丢帧,文件的 moov 完全合法,用任何播放器打开都不报错。
  2. 它错在每一帧、每一个像素上 ,但每个像素只错一点点。所以它跨过了你所有的防线------assert 是为"要么对要么崩"设计的,这个 bug 既不对也不崩。
  3. 靠记忆对比不出来。 必须把两张图放在一起,逐像素取色,才能确认"确实偏了"。
  4. 它有两个方向,一个可以救回来,另一个已经不可逆了。
  5. 修好之后你会发现,同一个根因在管线里还藏着另外三处。

这篇写的就是这一类。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

解码公式(CbCr 已归一化到 [-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 的输出也要显式声明

写文件的时候同理。不填 AVVideoColorPropertiesKeyAVAssetWriter 就按分辨率猜,然后把猜的结果写进 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)和 outputColorSpacerender 会做一次色彩空间转换。如果你在前面已经手工处理过一次,这里就是第二次。

如果你要的是"纯粹的像素搬运,别动我的颜色":

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=16Y=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 都别写。

  • 预览:AVCaptureVideoPreviewLayerAVSampleBufferDisplayLayer
  • 播放:AVPlayerLayer
  • 简单滤镜:CIFilter + CIContext 全托管
  • 录制:AVAssetWriter 直接 append 上游给的 CMSampleBuffer,别拆开

这些路径里,attachment 从采集一路带到编码,系统各层都正确读它。你不碰,就不会错。这篇文章里的九个坑,一个都遇不到。

你必须自己管的,是这五种情况:

  1. 自己写 Metal / OpenGL 渲染管线------你从 buffer 里取纹理的那一刻,就接管了解释权。
  2. 跨进程传 buffer------Broadcast Extension 到主 App、XPC、共享内存。中间只要重建过 buffer,attachment 就断了。
  3. 接第三方 SDK 给的 buffer------美颜、AI 模型、云厂商 SDK。它们输出的 buffer 带不带 attachment、带的对不对,你必须自己验,不能假设。
  4. 自己写 muxer 或推流 ------colr box、HEVC 的 VUI、SPS 里的 video_full_range_flagmatrix_coefficients,都得你自己填。
  5. 需要跨端一致------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=16Y=235、中灰 128 三个块的输出值贴出来------三个数字基本上就能定位到上面九个坑里的哪一个。


关于本文代码

文中的 Swift 与 Metal 示例用于说明思路和 API 调用形态,未逐行编译验证 ,直接复制到工程里需要补全上下文(顶点着色器与 VertexOut 定义、CVMetalTextureCache 的创建与生命周期管理、CVBufferGetAttachmentUnmanaged 桥接细节、simd_float3x3 的列主序约定与你的矩阵约定是否一致、错误处理与线程约束等)。

文中的数值均由 BT.601 / BT.709 / BT.2020 的公开系数与 sRGB 传递函数直接计算得出,可自行复算。涉及的系统行为(CVBufferPropagateAttachments 的传播范围、CIContext 的默认色彩管理、缺失 attachment 时按分辨率选择默认矩阵的约定)以公开文档与实际调试观察为准,不同 iOS 版本可能存在差异,请以你手上的设备实测为准。

相关推荐
深念Y1 小时前
黑苹果P400-直通显示折腾全记录-20260907
macos·ios·系统·黑苹果·苹果·英伟达·p400
2501_915921431 小时前
抓包工具对比:Charles、TraceEagle、Wireshark、Fiddler 与 Proxyman
网络协议·计算机网络·网络安全·ios·adb·https·udp
9765033352 小时前
iOS 上架/审核 4.3a全面解读-最新
flutter·ios·objective-c·uniapp
2501_915106323 小时前
Flutter iOS混淆打包详细教程与步骤
android·flutter·ios·小程序·uni-app·iphone·webview
外域速览7 小时前
OpenAI 智能体「越狱」风波未平,苹果折叠屏 iPhone Ultra 下周三登场
人工智能·ios·iphone
getapi1 天前
1 在 macOS 电脑上更新 Flutter iOS 应用到 App Store
flutter·macos·ios
可乐鸡翅yeah_1 天前
App WebView 加载 M3U8 流媒体踩坑,安卓 iOS 混合开发播放异常定位
android·ios·harmonyos·m3u8·m3u8在线播放
代码的小搬运工2 天前
KVO学习
学习·ios·cocoa
方白羽2 天前
为什么 Android 非要用 Intent 传值?
android·ios·harmonyos