这是系列最后一篇,讲一个肉眼可见的小瑕疵:画面底部一条绿带。它前后修了两轮,每一次都以为修好了,每一次都换了个姿势复发。病根始终是一个数字差:ISP 输出 1920×1088,而实际有效帧只有 1080 行。
背景:1088 从哪来
第一篇提过,media 链路上所有 pad 格式统一为 1920×1080(imx219 驱动会把 1088 clamp 成 1080)。但到了 ISP 主输出节点 /dev/video1,G_FMT 回读的却是 1920×1088 NV12(sizeimage=3133440)——当时以为是 VIN 驱动强制把高度按 16 对齐。实际出帧的有效数据仍是 1080 行(bytesused=3110400)。
2026-08-25 更正:1088 其实不是驱动强制的,是我们自己请来的——采集代码当时向 video1 请求的就是 1088,驱动照单全收。后来改成请求 1080,G_FMT 回读就是干干净净的 1920×1080(sizeimage=3110400),那 8 行填充从驱动侧就消失了。这个更正本身是一场事故换来的,见文末补记的第三轮。
NV12 是半平面格式:先是 宽×高 字节的 Y 平面(亮度),紧跟 宽×高/2 字节的 UV 交错平面(色度,横竖分辨率各减半,所以只有四分之一数据量)。UV 平面的位置不是存在某个头里的,是从「宽×高」算出来的——这意味着「高度按 1080 算还是按 1088 算」会直接决定你读到的 UV 是不是错位 8 行。绿带的全部故事,都是这 8 行闹的:
flowchart TD
subgraph F["一帧 NV12 数据的内存布局"]
direction TB
Y["Y 亮度平面<br/>宽 × 高 字节"]
UV["UV 色度交错平面<br/>宽 × 高 ÷ 2 字节<br/>起始偏移 = 宽 × 高"]
Y --> UV
end
第一轮:帧池的零填充外露
采集线程把帧拷进预分配的环形帧池,槽位按 1088 对齐分配(1920×1088×1.5 字节)。早期实现里,编码线程取帧时直接用整个槽位——而 1080 行的真实帧拷进去后,尾部 8 行是槽位里残留的零填充。
后果链是这样的:编码侧从「帧数据长度」反推高度(cap_h = len×2/(宽×3)),拿到整个槽位就把 cap_h 错推成 1088;接着按 1088 布局去定位 UV 平面,基址比真实位置偏了 8 行——UV 全部读串,画面底部出现绿带。
修复很直接:帧池加一个 used_len 字段,记录最近一帧的真实字节数,取帧时只暴露实际数据部分:
struct FramePool {
slots: Vec<Vec<u8>>, // 预分配缓冲(含对齐填充,不逐帧分配)
head: usize,
tail: usize,
count: usize,
used_len: usize, // 最近一帧实际字节数(≤槽位容量;1080 帧比槽位小 8 行)
}
fn push(&mut self, data: &[u8]) -> bool {
// ...
// 记录真实帧长:槽位按 1088 对齐分配,1080 帧尾部留 0 不得外露
// (否则 cap_h 被错推成 1088,UV 平面基址偏移 8 行 → 底部绿带)。
self.used_len = data.len();
// ...
}
第二轮:天真的 resize(0)
第一轮之后,编码侧拿到了「干干净净的 1080 行」。但编码器是按 1088 打开的(CTB 网格对齐),输入必须补足 8 行。最省事的做法呼之欲出:
frame.resize(1920 * 1088 * 3 / 2, 0); // 别这么做!
这行代码的效果堪称灾难,2026-08-13 实测定位:编码器按 1920×1088 的布局去读——Y 平面多读的 8 行吃掉了 UV 平面的前 8 行(这 8 行色度被当成亮度编了进去),而 UV 平面按新布局从更靠后的位置开始读,尾部缺 12 行,由 resize 补的零填充。末端区域于是变成「Y 是真实画面 + UV=0」。
flowchart TB
subgraph WRONG["❌ 错误做法:尾部 resize(0)"]
direction TB
W1["Y 真实 1080 行"] --> W2["UV 前 8 行<br/>被编码器当成 Y 读走"]
W2 --> W3["UV 剩余行 + 尾部 12 行补零<br/>→ 底部 24 行纯绿"]
end
subgraph RIGHT["✅ 正确做法:按平面重组 + 末行复制"]
direction TB
R1["Y:真实 1080 行<br/>+ 末行复制 ×8"] --> R2["UV:540 行搬到新基址<br/>+ 末行复制 ×4"]
end
WRONG -.->|"改用"| RIGHT
UV=0 解码出来是什么颜色?纯绿,RGB(0,135,0)。按 BT.601 的 YUV→RGB 转换,U=V=0 意味着色度取最大负偏移:绿通道拉满、红蓝归零——所以任何「色度全零」的区域都是这种标志性的纯绿。底部 24 行绿带就是这么来的(Y 平面 8 行假数据 + UV 12 行零填充,合计效果)。
正确的做法是按 NV12 平面结构重组,而不是线性扩长度:
// Y:真实 1080 行 + 末行复制 ×8
buf[..y_real].copy_from_slice(&frame[..y_real]);
let last_y = &frame[y_real - w..y_real];
for r in 0..(ENC_H as usize - cap_h) {
let off = y_real + r * w;
buf[off..off + w].copy_from_slice(last_y);
}
// UV:真实 540 行搬到 1088 布局的 UV 基址 + 末行复制 ×4
buf[y_full..y_full + uv_real].copy_from_slice(&frame[y_real..y_real + uv_real]);
let last_uv = &frame[y_real + uv_real - w..y_real + uv_real];
for r in 0..(ENC_H as usize - cap_h) / 2 {
let off = y_full + uv_real + r * w;
buf[off..off + w].copy_from_slice(last_uv);
}
为什么补「复制最后一真实行」,而不是填 0 或填 128:
- UV 填 0:纯绿 (0,135,0),就是那条绿带本身;
- UV 填 128:理论上是中性灰,但会与我们自己的软件白平衡偏移叠加(见下节),叠加完就有色差了;
- 复制末行:补齐区域的颜色延续画面边缘,在任何播放器的裁剪/显示行为下都无痕。
番外:软件白平衡为什么融合进 UV 拷贝
顺手记录一个相关的性能技巧。这个镜像的 ISP AWB 是失效的,画面偏色(sensor 1080 模式下 U 基准实测约 154,而中性值是 128),所以我们在编码前做软件白平衡:每 30 帧统计一次 UV 均值,自动收敛到 128(用户也可用滑块手动覆盖)。
关键是偏移量的施加位置:不做独立遍历,而是融合进 C 侧本来就存在的 UV 平面拷贝里(cam_hevc_encode 拷帧时顺手加偏移、钳位 0..255),零额外开销。实测如果在 Rust 侧单独做一遍整平面遍历,1MB 数据的来回会掉约 9fps——对一颗只有 21fps 编码能力的 VPU 来说不可接受。
还有个收尾优化:自适应收敛值通常就在 ±3 以内(≈1% 色偏,肉眼不可辨),而实测发现这个标量循环和纯 memcpy「同价」的结论只在偏移非零时成立——所以 |偏移|<4 时直接传 0/0 走纯 memcpy 分支,稳态满速。
补记:第三轮——1088 这个高度根本不该请求(2026-08-25)
以为故事完了,结果绿带的病根又以更凶的姿势复发了一次。2026 年 8 月下旬把摄像头模组换成 IMX219 IR-CUT 版后,画面直接花屏+错位——不是底部一条带,是整幅画面撕裂、色块横飞。裸 v4l2-ctl 抓帧却是干净的,说明 sensor 和编码器都没问题,嫌疑落在用户态的采集配置上。
为排除 Rust 程序的其它干扰,我们在板上写了个 C 程序完整复刻采集路径做 A/B 实验,结论干净利落:
S_FMT请求 1920×1088 → 花屏复现;S_FMT请求 1920×1080 → 画面干净。
也就是说前两轮修的绿带都只是「下游怎么消化那 8 行填充」,而真正的雷埋在源头:不该向 VIN 节点请求 1088。sensor 输出 1080 行,请求 1088 会让管线的协商几何和实际数据错位,逐行错移累积成整幅花屏。之前没炸只是侥幸,换模组后管线状态略有差异就爆了。
修法是把三个高度常量彻底分家,各管各的:
| 常量 | 值 | 用途 |
|---|---|---|
FMT_H | 1080 | S_FMT/G_FMT 协商——对驱动永远说 1080 |
CAP_H | 1088 | 帧池槽位容量——只决定缓冲区开多大 |
ENC_H | 1088 | 编码器输入高度——CTB 对齐,送编码前平面重组补 8 行 |
修复后帧间差异指标(每帧抽样 576 点的均差)从约 2 万掉到约 1100,WS 解码帧目检干净。前面两轮的修复(used_len、平面重组)依然保留——编码器仍按 1088 打开,1080 行真实帧补齐 8 行的步骤照旧,只是现在驱动给的帧从第一行到最后一行全是真实数据,再也没有需要遮遮掩掩的填充区了。
系列总结
五篇下来,这套系统的最终形态:
- 采集:开机脚本恢复 media 管线 → 程序先健康检查再决定是否复位 → 用户态手动救 sensor(配格式、开 MCLK、I2C 写 Stream On)→
/dev/video1按协商的 1920×1080 稳定输出 NV12(2026-08-25 起 S_FMT 直接请求 1080,不再碰 1088); - 编码:WAVE420L 硬编 HEVC,1080p CBR 6000kbps、GOP 30、约 21fps,错误路径靠 broken 标志 + systemd 复位自愈;
- 串流:WebSocket 广播(容量 2 队列、满则丢帧、编码线程永不阻塞)→ 浏览器三级降级(WebCodecs hev1 → hvc1 → MSE fMP4),392 行手工 fMP4 封装器兜底。
如果只能带走几条经验:
- 硬件 SDK 报错先怀疑自己:调用顺序、时序(等中断)、参数单位/范围,都能伪装成「固件不匹配」;
- 软硬解行为差异本身就是线索:软解宽容、硬解严格,「只有硬解坏了」往往意味着码流有规范性瑕疵(NAL 类型、截断、参数集),而不是性能问题;
- 多平面格式的高度差 1 行都不是小事:NV12 的 UV 基址由高度算出,1088 与 1080 差 8 行足以毁掉画面底部;
- 向驱动请求的几何必须与数据源头一致:sensor 出 1080 行就协商 1080,「多要 8 行对齐」这种小聪明会在管线里逐行错移成整幅花屏;
- 边界条件要在「最后一个元素」上单独过一遍:
split_annexb的 bug 只在末 NAL 上发作,前面 99% 的数据全是好的。
至此,VisionFive 2 的摄像头从「开机全断」跑到「网页里流畅的 1080p HEVC 直播」。祝你的 RISC-V 之旅少踩几个坑。