// engineering · methodology
校验像素导出,为什么必须比 YUV 平面
拿 RGBA 对拍时,10bit 素材出现了「单像素最大差 97 / 255」这个看起来像严重 bug 的数字, 而同一次比对的全图平均差不到 1.4。矛盾不在代码里,在口径里——RGBA 已经混进了一层我们并不想验证的东西。
一、两个互相矛盾的数字
当时的任务是给帧预览加一条「wasm 自己解出像素」的导出通路,验收方式是与 ffmpeg 的命令行输出对拍。 第一版选的对拍口径是 RGBA:两边都转成 RGBA,然后逐像素比较。
| 素材 | 结果 |
|---|---|
| 8bit 源 | 正常 |
| 10bit 源 | 逐像素最大差 97 / 255 |
97 这个数字单看就是「严重 bug」。但同一次比对里,全图平均绝对差小于 1.4—— 也就是说画面对得上,只有极少数像素差得离谱。
这两个数字放在一起,本身就是最有价值的信息。误差的分布形状能指出根因的类型:
| 分布形状 | 指向 |
|---|---|
| 全图均匀偏移,平均差很大(如 92) | 数值映射错了——每一个样本都算错 |
| 平均差很小,个别像素差极大 | 误差是空间局部的——某个只在特定位置起作用的分支 |
这次属于第二种,所以嫌疑不该落在「解码 / 数值换算」上。
二、根因不是代码,是口径
RGBA 里已经包含了一个决策:色度上采样。
YUV 4:2:0 的色度分辨率只有亮度的一半(宽高各一半)。当它被转成 RGB 时,必须为每一个像素生成 色度值,而这需要一个插值策略:
| 实现 | 色度取值方式 |
|---|---|
| 本项目导出通路 | 取每个亮度块对应位置的中心样本(最近邻风格) |
| swscale(ffmpeg) | 双线性插值 |
在平坦色块上,两种策略几乎给出同样的结果。但在颜色边缘——红字蓝底、彩色高亮图形、 大面积色块的边界——两者的差距可以很大,而且只影响边缘那一两圈像素。
于是「平均差 < 1.4、最大差 97」这个分布就被完整解释了:误差空间局部、幅度大。 这不是我们的 bug,也不是 ffmpeg 的 bug,是两个不同的、都合理的实现选择。
三、结论:换口径,不是改代码
正确的做法是绕开色彩转换,直接比平面。让 ffmpeg 输出原始 YUV:
ffmpeg -i input.mp4 \
-vf "select='eq(n\,<显示序>)'" -vsync 0 \
-pix_fmt yuv420p -f rawvideo ref.yuv
然后与导出的 Y | U | V 三段逐字节比较:
| 素材 | 判据 | 结果 |
|---|---|---|
| 8bit 源 | 逐字节相同 | 3110400 / 3110400 字节完全相同 |
| 10bit 源 | 按 shift + depth - 8 右移后,差 > 1 的字节数 |
0 个 |
(3110400 = 1920 × 1080 × 1.5,正好是一帧 4:2:0 的字节数。)
这个结论比 RGBA 那次强得多:它不是「看着差不多」,而是「逐字节相同」——一条可以直接写进文档、 以后每次改数据流都能重跑的判据。
四、为什么允许「差 1」
10bit 那一条允许 1 的误差,来源是两次舍入:源本身是 10bit,ffmpeg 输出 8bit 参考值时舍入一次,我们的通路也舍入一次。两次方向不同就会差 1。
这没有把标准放松。注意判据的变化:
- 旧口径:「平均差小于某个数」——问题是「多少算多」需要拍一个数,而这个数没有依据;
- 新口径:「差超过 1 的字节数必须为 0」——精确、可枚举、不留解释空间。
反过来,如果写成「平均差 < 2」或者「差 < 4」,这个判据就失效了——它会把真正的小幅系统性 错误一起放过去。判据的严格程度要卡在舍入误差的上界,而不是卡在「看起来还行」。
五、同一批改动还该做两个检查
平面逐字节相同只是第一层。导出通路还有两个维度,各自出过问题:
1. 分片不变性
实际的导出是在 worker 里分片喂帧的(拆片是为了能在每片之间上报进度、响应取消)。所以把 worker 的 真实形状复刻进验证脚本——窗口化装载 + 分片喂帧——然后把分片大小取 1 / 3 / 8 / 1000 各跑一遍。
结果必须完全一致。分片是一个控制流装置,不是数值变换;如果改变分片大小会改变像素, 那才是真 bug。这条检查便宜到几乎没理由不做。
2. 消息协议层
只比 C 函数是不够的。做法是把构建产物的 worker chunk 加载到 Node 里
(伪造 self / importScripts / postMessage),真的走一遍
begin / step / finish / cancel 四个 RPC。
这一层能抓到 C 层测不出的问题:
- 取消之后,旧令牌是否真的失效(任务是否真的被释放);
- 进度回调的点数是否等于分片数;
- 上报的画面尺寸是不是解码后的真实像素尺寸;
- 返回的位图尺寸与 RGBA 字节数是否自洽。
这一层真的抓到过一次:断言「上报尺寸 == 容器里的 track.width」失败。原因是那个字段的值是
1919.593262——来自 tkhd 的定点表示,而不是像素数。改成比较
宽高比(误差 < 1%)之后才对。这类问题只会在协议层暴露:C 层算得没错,
元数据本身就不是整数。
六、可迁移的经验
- 比「转换之后的东西」,会把转换策略一起比进去。想验证 A→B 的转换,先找到能绕开 多余层级的中间表示。这次是 YUV 平面,别处可能是中间张量、序列化前的对象、或者未格式化的原始字节。
- 平均差和最大差要一起看。只看平均会漏掉局部的严重错误;只看最大会把策略差异 误判成 bug。两者的组合才能指出误差的形状。
- 判据要能被枚举。「差 > 1 的字节数 = 0」比「平均差 < 1.4」强得多, 因为它消掉了「多少算多」这个需要拍脑袋的问题。
- 协议层也要测。单元级正确不等于集成正确。把构建产物在 Node 里真跑一遍协议, 成本很低,覆盖面却包含参数校验、令牌生命周期、尺寸自洽这些只有集成时才会错的东西。
相关:10bit 素材整幅变黑 · 浏览器解码器整片拒收,那就自己解 · 技术原理 · 打开分析器
// engineering · methodology
Why pixel export has to be verified on YUV planes
Diffing RGBA produced a per-pixel maximum difference of 97 of 255 on 10-bit material — a number that looks like a serious bug — while the whole-frame mean for the same comparison stayed under 1.4. The contradiction was not in the code but in the measurement: RGBA already contains a layer we never meant to verify.
1. Two numbers that contradict each other
The task was to add a path exporting decoded pixels from wasm, so frame preview can fall back to a software decoder. Verification meant diffing against ffmpeg's command-line output. The first attempt compared RGBA: convert both sides to RGBA, then compare pixel by pixel.
| Clip | Result |
|---|---|
| 8-bit source | Fine |
| 10-bit source | Per-pixel maximum difference 97 of 255 |
Read alone, 97 means "serious bug". But in the same comparison the whole-frame mean absolute difference was under 1.4 — the pictures line up, with a handful of pixels wildly off.
Taken together, those two numbers are the most valuable information available. The shape of the error distribution tells you the class of cause:
| Distribution | Points to |
|---|---|
| Uniform offset, large mean (e.g. 92) | A wrong numeric mapping — every sample computed incorrectly |
| Tiny mean, enormous individual differences | Error is spatially local — a branch that only fires in certain places |
This was the second shape, so the suspicion should never have been on decode or numeric conversion.
2. The cause was the measurement, not the code
RGBA already contains a decision: chroma upsampling.
In YUV 4:2:0 the chroma resolution is half that of luma (half in each dimension). Converting to RGB means generating a chroma value for every pixel, which requires an interpolation policy:
| Implementation | How chroma is sampled |
|---|---|
| Our export path | The centre sample of each corresponding luma block (nearest-neighbour style) |
| swscale (ffmpeg) | Bilinear interpolation |
On flat colour the two agree almost exactly. On colour edges — red text on blue, a saturated highlight, the boundary of a large colour block — they can differ substantially, affecting only the one- or two-pixel rim.
That fully explains "mean < 1.4, maximum 97": spatially local, large in magnitude. Not our bug, not ffmpeg's bug — two different, both defensible implementation choices.
3. The conclusion: change the measurement, not the code
Bypass colour conversion and compare planes. Have ffmpeg emit raw YUV:
ffmpeg -i input.mp4 \
-vf "select='eq(n\,<display index>)'" -vsync 0 \
-pix_fmt yuv420p -f rawvideo ref.yuv
| Clip | Criterion | Result |
|---|---|---|
| 8-bit source | Byte-for-byte identical | 3110400 / 3110400 bytes identical |
| 10-bit source | Bytes differing by more than 1 after shifting by shift + depth - 8 |
Zero |
(3110400 = 1920 × 1080 × 1.5, exactly one 4:2:0 frame.)
This is a much stronger claim than the RGBA attempt: not "close enough" but "byte-for-byte identical" — a criterion you can document and re-run on every future change to the pixel path.
4. Why a difference of 1 is tolerated
The tolerance on 10-bit comes from two roundings: the source is 10-bit, ffmpeg rounds once producing an 8-bit reference, and our path rounds once. Opposite directions give a difference of 1.
That does not relax the standard. Note what changed:
- Old: "mean difference below some number" — which requires inventing the number, with nothing to base it on;
- New: "the count of bytes differing by more than 1 must be zero" — exact, enumerable, no room for interpretation.
Conversely, writing "mean < 2" or "difference < 4" would destroy the criterion, because it would also wave through a genuine small systematic error. Tighten the criterion to the upper bound of the rounding error, not to "looks about right".
5. Two more checks the same change needs
1. Chunking invariance
The real export feeds frames in chunks inside a worker (chunking exists so progress and cancellation can be handled between chunks). So replay the worker's actual shape — windowed load plus chunked feed — with chunk sizes of 1 / 3 / 8 / 1000.
The output must be identical every time. Chunking is a control-flow device, not a numeric transform; if changing the chunk size changed the pixels, that would be a real bug. The check is cheap enough that skipping it makes no sense.
2. The message protocol layer
Comparing the C function is not enough. Load the built worker chunk in Node (faking
self, importScripts and postMessage) and actually drive the four
RPCs: begin / step / finish / cancel. This layer
catches things the C layer cannot:
- whether an old token is genuinely invalidated after cancellation (is the job really released?);
- whether the number of progress ticks equals the number of chunks;
- whether the reported picture size is the decoded pixel size;
- whether the returned bitmap dimensions and RGBA byte count are self-consistent.
This layer did catch something: the assertion "reported size equals the container's
track.width" failed, because that field read 1919.593262 — a fixed-point
value from tkhd, not a pixel count. Switching to an aspect-ratio
comparison (within 1%) was the correct fix. Only the protocol layer exposes this class of problem:
the C side computes correctly, and the metadata was never an integer.
6. Transferable lessons
- Diffing "the thing after conversion" drags the conversion policy into the comparison. To verify an A→B transform, find an intermediate representation that bypasses the extra layers. Here that was YUV planes; elsewhere it may be an intermediate tensor, a pre-serialisation object, or raw unformatted bytes.
- Read mean and maximum together. Mean alone hides severe local errors; maximum alone mistakes policy differences for bugs. Only the pair reveals the shape of the error.
- Make criteria enumerable. "Count of bytes differing by more than 1 equals zero" beats "mean below 1.4", because it removes the need to invent a threshold.
- Test the protocol layer too. Unit-level correctness is not integration correctness. Running the built artefact through its real protocol in Node is cheap and covers argument validation, token lifetime and size self-consistency — things that only fail when assembled.
Related: 10-bit footage renders black · when the browser decoder rejects a stream · How it works · Open the analyzer