NV12 转 RGB 完整指南

21 阅读8分钟

前言

由于浏览器无法直接显示 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

格式数据大小用途浏览器支持
RGBwidth × height × 3显示、编辑✅ 原生支持
YUV多种变体视频编码❌ 需要转换
NV12width × 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 的 VideoFrame API 可能提供 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.402359/256
let g = y - ((88 * u + 183 * v + 128) >> 8)  // 0.34488/256, 0.714183/256
let b = y + ((454 * u + 128) >> 8)      // 1.772454/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 的核心要点:

  1. NV12 是 YUV 4:2:0 格式,数据大小是 RGB 的一半
  2. 需要提前知道宽高,因为文件没有元数据
  3. 使用 Web Worker 进行后台转换,避免阻塞 UI
  4. UV 索引计算是关键,需要正确映射 4:2:0 采样
  5. 整数运算优化可以提升性能

这个转换在视频处理、图像处理、调试等场景中非常有用。