前言
由于浏览器无法直接显示 NV12 格式,需要转换为 RGB 才能在 Canvas 或 Image 中显示。
基础概念
什么是 YUV?
YUV 是一种颜色编码系统,与 RGB 不同:
什么是 NV12?
NV12 是 YUV 格式的一种,采用 4:2:0 采样:
RGB vs YUV vs NV12

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 采样说明
┌─────┬─────┐
│ 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 是其中一种):
const stream = await navigator.mediaDevices.getUserMedia({ video: true })
const videoTrack = stream.getVideoTracks()[0]
const imageCapture = new ImageCapture(videoTrack)
// 某些 API 可能返回 NV12 格式的原始数据
3. WebRTC 视频处理
4. 移动端开发
5. FFmpeg/视频处理库
# FFmpeg 处理视频时经常使用 NV12
ffmpeg -i input.mp4 -pix_fmt nv12 output.nv12
6. 硬件加速
为什么选择 NV12
1. 存储效率
对比:
RGB:1920 × 1080 × 3 = 6,220,800 字节
NV12:1920 × 1080 × 1.5 = 3,110,400 字节
节省:50% 存储空间
2. 硬件支持
大多数硬件编码器原生支持 NV12
转换成本低,性能好
3. 视频编码标准
4. 人眼特性
转换原理
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 密集型操作:
每个像素需要:
- 1 次 Y 值读取
- 1 次 UV 索引计算
- 1 次 UV 值读取
- 3 次乘法运算
- 3 次加法运算
- 3 次 clamp 操作
总计:约 2,000 万次运算
在主线程执行的问题:
❌ 阻塞 UI,页面卡顿
❌ 用户体验差
❌ 无法利用多核 CPU
使用 Web Worker 的优势:
✅ 后台处理,不阻塞 UI
✅ 利用多核 CPU
✅ 更好的用户体验
为什么需要提前知道图片的宽高?
1. 没有文件头/元数据
NV12 文件是纯像素数据,不像常见图片格式包含元数据:
┌─────────────┐
│ 文件头 │ ← 包含宽高、格式等信息
├─────────────┤
│ 元数据 │ ← 颜色空间、压缩参数等
├─────────────┤
│ 像素数据 │
└─────────────┘
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
如果宽高错误会怎样?

常见问题
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:
- 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 图像):
性能优化
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
worker.postMessage({ nv12, width, height }, [nv12.buffer])
总结
NV12 转 RGB 的核心要点:
NV12 是 YUV 4:2:0 格式,数据大小是 RGB 的一半
需要提前知道宽高,因为文件没有元数据
使用 Web Worker 进行后台转换,避免阻塞 UI
UV 索引计算是关键,需要正确映射 4:2:0 采样
整数运算优化可以提升性能
这个转换在视频处理、图像处理、调试等场景中非常有用。
参考文章:原文链接
该文章在 2026/1/9 8:34:05 编辑过