前言
由于浏览器无法直接显示 NV12 格式,需要转换为 RGB 才能在 Canvas 或 Image 中显示。
基础概念
什么是 YUV?
YUV 是一种颜色编码系统,与 RGB 不同:
- Y(亮度) :表示图像的明暗程度,人眼对亮度最敏感
- U(色度) :表示蓝色与亮度的差值
- V(色度) :表示红色与亮度的差值
什么是 NV12?
NV12 是 YUV 格式的一种,采用 4:2:0 采样:
- 4:2:0 表示:每 4 个 Y 像素共享 1 个 UV 像素对
- 存储方式:Y 平面完整存储,UV 平面交错存储(U、V、U、V...)
- 数据大小:
width × height × 1.5字节
RGB vs YUV vs NV12
| 格式 | 数据大小 | 用途 | 浏览器支持 |
|---|---|---|---|
| RGB | width × height × 3 | 显示、编辑 | ✅ 原生支持 |
| YUV | 多种变体 | 视频编码 | ❌ 需要转换 |
| NV12 | width × height × 1.5 | 视频处理 | ❌ 需要转换 |
NV12 格式详解
数据布局
NV12 数据由两部分组成:
┌─────────────────────────────────┐
│ Y 平面(亮度) │
│ 大小:width × height 字节 │
│ 每个像素 1 字节 │
├─────────────────────────────────┤
│ UV 平面(色度,交错存储) │
│ 大小:width × height / 2 字节 │
│ 格式:U V U V U V ... │
└─────────────────────────────────┘
总大小 = width × height × 1.5 字节
示例:1920×1080 图像
Y 平面:1920 × 1080 = 2,073,600 字节
UV 平面:1920 × 1080 / 2 = 1,036,800 字节
总大小:3,110,400 字节
数据排列:
[Y Y Y Y ... Y] [U V U V U V ... U V]
↑ 2,073,600 字节 ↑ 1,036,800 字节
4:2:0 采样说明
像素布局(2×2 像素块):
┌─────┬─────┐
│ Y₁ │ Y₂ │ ← 4 个 Y 值
├─────┼─────┤
│ Y₃ │ Y₄ │
└─────┴─────┘
↓
共享 1 个 UV 对
↓
[U, V]
为什么这样设计?
- 人眼对亮度(Y)更敏感,对色度(UV)不敏感
- 减少色度数据可以节省 50% 存储空间
- 视频编码标准(H.264/H.265)基于此原理
使用场景
1. 视频编码/解码
视频编解码器(H.264、H.265/HEVC、VP8、VP9)的中间格式:
视频文件 → 解码器 → NV12 格式帧 → 转换为 RGB → 显示
2. 摄像头/视频流
摄像头采集的原始数据通常是 YUV(NV12 是其中一种):
// WebRTC 示例
const stream = await navigator.mediaDevices.getUserMedia({ video: true })
const videoTrack = stream.getVideoTracks()[0]
const imageCapture = new ImageCapture(videoTrack)
// 某些 API 可能返回 NV12 格式的原始数据
3. WebRTC 视频处理
- WebRTC 的
VideoFrameAPI 可能提供 NV12 格式 - 视频会议、直播等场景中的原始帧数据
4. 移动端开发
- Android:Camera2 API 输出的格式之一(
ImageFormat.YUV_420_888) - iOS:VideoToolbox 可能输出 NV12
- 移动端视频处理库的常见输出格式
5. FFmpeg/视频处理库
# FFmpeg 处理视频时经常使用 NV12
ffmpeg -i input.mp4 -pix_fmt nv12 output.nv12
6. 硬件加速
- GPU 视频解码器(NVIDIA NVENC、Intel Quick Sync)的输出
- 硬件编码器通常使用 NV12 作为输入/输出格式
为什么选择 NV12
1. 存储效率
对比:
1920 × 1080 图像:
RGB:1920 × 1080 × 3 = 6,220,800 字节
NV12:1920 × 1080 × 1.5 = 3,110,400 字节
节省:50% 存储空间
2. 硬件支持
- 大多数硬件编码器原生支持 NV12
- 转换成本低,性能好
3. 视频编码标准
- 主流视频编码标准(H.264、H.265)基于 YUV
- NV12 是常见的 YUV 格式,兼容性好
4. 人眼特性
- 人眼对亮度(Y)敏感,对色度(UV)不敏感
- 4:2:0 采样符合人眼特性,在视觉质量损失很小的情况下大幅减少数据量
转换原理
YUV → RGB 转换公式
我们使用 ITU-R BT.601 标准的转换公式:
// 标准公式(浮点)
R = Y + 1.402 × (V - 128)
G = Y - 0.344 × (U - 128) - 0.714 × (V - 128)
B = Y + 1.772 × (U - 128)
// 优化后的整数运算(代码中使用)
let r = y + ((359 * v + 128) >> 8) // 1.402 ≈ 359/256
let g = y - ((88 * u + 183 * v + 128) >> 8) // 0.344≈88/256, 0.714≈183/256
let b = y + ((454 * u + 128) >> 8) // 1.772 ≈ 454/256
// 限制值在 0-255 范围内
r = r < 0 ? 0 : r > 255 ? 255 : r
g = g < 0 ? 0 : g > 255 ? 255 : g
b = b < 0 ? 0 : b > 255 ? 255 : b
为什么使用整数运算?
>> 8相当于除以 256,比浮点运算快- 避免浮点数精度问题
- 性能更好
转换流程
┌─────────────────┐
│ NV12 原始数据 │
└────────┬────────┘
│
▼
┌─────────────────┐
│ 提取 Y 平面 │ ← width × height 字节
│ (亮度信息) │
└────────┬────────┘
│
▼
┌─────────────────┐
│ 提取 UV 平面 │ ← width × height / 2 字节
│ (色度,交错) │
└────────┬────────┘
│
▼
┌─────────────────┐
│ 对每个像素: │
│ 1. 计算 UV 索引│ ← 4:2:0 采样映射
│ 2. 应用转换公式│ ← YUV → RGB
│ 3. 限制值范围 │ ← 0-255 clamp
└────────┬────────┘
│
▼
┌─────────────────┐
│ 生成 RGBA 数据 │ ← width × height × 4 字节
└────────┬────────┘
│
▼
┌─────────────────┐
│ 绘制到 Canvas │
└─────────────────┘
UV 索引计算详解
这是转换中最关键的部分:
// 对于像素位置 (x, y),计算其在数组中的索引
const i = y * width + x
// 计算对应的 UV 行(4:2:0 采样,UV 行是 Y 行的一半)
const uvRow = (i / width | 0) >> 1 // 等价于 Math.floor(y / 2)
// 计算对应的 UV 列(必须是偶数)
const uvCol = (i % width) & ~1 // 等价于 Math.floor(x / 2) * 2
// 计算 UV 在数组中的索引
const uvIndex = uvRow * width + uvCol
// 获取 U 和 V 值
const u = uv[uvIndex] - 128 // U 值(偏移 128)
const v = uv[uvIndex + 1] - 128 // V 值(偏移 128)
示例:像素 (100, 50) 在 1920×1080 图像中
i = 50 * 1920 + 100 = 96,100
uvRow = (96,100 / 1920) >> 1 = 25
uvCol = (96,100 % 1920) & ~1 = 100
uvIndex = 25 * 1920 + 100 = 48,100
// 读取 UV 值
u = uv[48,100] - 128
v = uv[48,101] - 128
实现细节
为什么使用 Web Worker?
转换是 CPU 密集型操作:
1920 × 1080 图像 = 2,073,600 像素
每个像素需要:
- 1 次 Y 值读取
- 1 次 UV 索引计算
- 1 次 UV 值读取
- 3 次乘法运算
- 3 次加法运算
- 3 次 clamp 操作
总计:约 2,000 万次运算
在主线程执行的问题:
- ❌ 阻塞 UI,页面卡顿
- ❌ 用户体验差
- ❌ 无法利用多核 CPU
使用 Web Worker 的优势:
- ✅ 后台处理,不阻塞 UI
- ✅ 利用多核 CPU
- ✅ 更好的用户体验
为什么需要提前知道图片的宽高?
1. 没有文件头/元数据
NV12 文件是纯像素数据,不像常见图片格式包含元数据:
PNG/JPEG 文件结构:
┌─────────────┐
│ 文件头 │ ← 包含宽高、格式等信息
├─────────────┤
│ 元数据 │ ← 颜色空间、压缩参数等
├─────────────┤
│ 像素数据 │
└─────────────┘
NV12 文件结构:
┌─────────────┐
│ 像素数据 │ ← 只有原始字节,没有任何元数据!
└─────────────┘
2. 需要宽高来计算数据布局
// nv12Worker.ts
const { nv12, width, height } = e.data
const yLen = width * height
const uv = nv12.subarray(yLen) // 需要知道 yLen 才能分割数据
3. 需要宽高来验证文件大小
const expectedSize = width * height * 1.5
if (file.size !== expectedSize) {
message.error(
`文件大小不匹配!期望: ${expectedSize} 字节,实际: ${file.size} 字节`
)
return
}
4. 需要宽高来正确解析 UV 数据
UV 数据的索引计算完全依赖宽高:
const uvRow = (i / width | 0) >> 1 // 需要 width
const uvCol = (i % width) & ~1 // 需要 width
const uvIndex = uvRow * width + uvCol // 需要 width
如果宽高错误会怎样?
| 错误类型 | 后果 | 严重程度 |
|---|---|---|
| 文件大小不匹配 | 验证失败,无法处理 | ⭐⭐⭐ |
| Y/UV 分割错误 | 读取错误的数据段 | ⭐⭐⭐⭐⭐ |
| UV 索引错误 | 颜色完全错乱 | ⭐⭐⭐⭐⭐ |
| 数组越界 | 程序崩溃 | ⭐⭐⭐⭐⭐ |
常见问题
Q1: 如何获取 NV12 文件的宽高?
方法 1:从视频元数据获取
const videoTrack = stream.getVideoTracks()[0]
const settings = videoTrack.getSettings()
const width = settings.width // 1920
const height = settings.height // 1080
方法 2:从文件名解析
// 例如:frame_1920x1080.nv12
const match = filename.match(/(\d+)x(\d+)/)
const width = parseInt(match[1])
const height = parseInt(match[2])
方法 3:从配置文件/API
const response = await fetch('/api/video-frame')
const { width, height, data } = await response.json()
// data 是 NV12 格式的二进制数据
Q2: 如果宽高设置错误会怎样?
- 文件大小验证失败:程序拒绝处理
- 数据分割错误:Y 和 UV 数据读取位置错误
- UV 索引错误:所有颜色错位,图像完全错乱
- 数组越界:可能导致程序崩溃
Q3: 为什么 UV 数据要偏移 128?
YUV 格式中,U 和 V 的值范围是 0-255,但实际表示的是 -128 到 +127:
- 0 表示 -128(最蓝/最绿)
- 128 表示 0(中性)
- 255 表示 +127(最红/最黄)
所以在转换时需要减去 128 来恢复真实值。
Q4: 可以处理其他 YUV 格式吗?
NV12 是 YUV 4:2:0 的一种。其他常见格式:
- NV21:UV 顺序相反(V、U、V、U...)
- I420:Y、U、V 分别存储
- YV12:Y、V、U 分别存储
需要修改代码来适配不同的格式。
Q5: 性能如何?
测试结果(1920×1080 图像):
- 转换时间:约 20-50ms(取决于 CPU)
- 内存占用:约 12MB(输入 3MB + 输出 8MB)
- 帧率:可以处理 20-50 FPS
性能优化
1. 使用 Web Worker
将 CPU 密集型操作移到后台线程,避免阻塞 UI。
2. 整数运算优化
使用位运算代替浮点运算:
// 慢
r = y + 1.402 * (v - 128)
// 快
r = y + ((359 * v + 128) >> 8)
3. 手动 Clamp
避免使用 Math.min/max:
// 慢
r = Math.max(0, Math.min(255, r))
// 快
r = r < 0 ? 0 : r > 255 ? 255 : r
4. 减少函数调用
在循环中内联代码,减少函数调用开销。
5. 使用 Transferable Objects
// 传递 ArrayBuffer,避免复制
worker.postMessage({ nv12, width, height }, [nv12.buffer])
总结
NV12 转 RGB 的核心要点:
- NV12 是 YUV 4:2:0 格式,数据大小是 RGB 的一半
- 需要提前知道宽高,因为文件没有元数据
- 使用 Web Worker 进行后台转换,避免阻塞 UI
- UV 索引计算是关键,需要正确映射 4:2:0 采样
- 整数运算优化可以提升性能
这个转换在视频处理、图像处理、调试等场景中非常有用。