2、RAW数据结构解析:RAW文件头部信息、元数据(EXIF)提取、图像数据载荷、黑电平(Black Level)与白平衡(White Balance)参数

好,咱们直接进入正题。

上一章我们聊了RAW是什么,以及它为什么这么「硬核」。这一章,我们来拆解它。说白了,就是要把一个RAW文件大卸八块,看看里面到底藏了些什么。

我个人习惯把RAW文件想象成一个「集装箱」。最外面是包装箱(文件头部),里面有一张说明书(元数据/EXIF),然后才是真正的货物(图像数据载荷)。货物旁边还贴着两张标签,一张写着「黑电平」,一张写着「白平衡」。嗯,咱们一个一个来拆。

2.1 RAW文件头部信息:集装箱的「包装箱」

每个RAW文件的开头,都有一段固定的头部信息。不同厂商(索尼、佳能、尼康)的格式不同,但核心逻辑一样:告诉解析器「我是谁,我多大,我怎么排列的」。

我举个例子,这是最常见的 Bayer RAW 文件头部结构(以某款常见传感器为例):

// 伪代码,展示头部关键字段
typedef struct {
    uint16_t  magic;          // 魔数,用于校验文件类型,比如 0x002A
    uint32_t  image_width;    // 图像宽度,单位像素
    uint32_t  image_height;   // 图像高度
    uint16_t  bayer_pattern;  // Bayer排列模式:0=RGGB, 1=BGGR, 2=GRBG, 3=GBRG
    uint16_t  bit_depth;      // 位深,常见10bit、12bit、14bit
    uint32_t  data_offset;    // 图像数据起始偏移地址
    uint32_t  file_size;      // 整个文件大小
    // ... 其他厂商自定义字段
} RAW_HEADER;

这里有个坑,我踩过。很多初学者以为 data_offset 就是固定的,直接硬编码去读。结果换了台相机,文件就读不出来了。为什么?因为不同型号的相机,头部长度可能不一样。所以,永远不要硬编码偏移量,一定要从头部字段里读。

⚠️ 避坑指南: 我曾经在解析一款工业相机RAW时,发现图像全是斜条纹。查了半天,原来是头部信息里的 image_width 和实际数据不匹配——厂商在头部里填的是「有效像素」,但数据里包含了「暗像素」。从那以后,我每次都会用 data_offsetfile_size 反算一下数据长度,做一次交叉校验。

2.2 元数据(EXIF)提取:集装箱的「说明书」

头部信息之后,紧跟着的就是元数据。大部分RAW格式(如DNG、CR2、NEF)都内嵌了EXIF数据。这里面记录了拍摄时的所有参数:快门速度、ISO、光圈、镜头型号,甚至GPS坐标。

提取EXIF,我个人推荐用 libexif 或者 ExifTool。但在嵌入式环境里,我们往往需要自己解析。核心思路是:找到EXIF的起始标记(通常是 0xFFE1 或特定字符串),然后按TIFF结构逐级解析。

一个典型的EXIF提取流程:

  1. 扫描文件,定位到EXIF APP1 标记。
  2. 读取TIFF头部,判断字节序(Intel 还是 Motorola)。
  3. 遍历IFD(图像文件目录),找到你需要的Tag。
  4. 根据Tag的数据类型,读取对应的值。

举个例子,提取ISO感光度:

// 伪代码:从EXIF中提取ISO
uint16_t exif_get_iso(uint8_t *exif_data, uint32_t exif_size) {
    // 1. 跳过APP1标记和长度
    // 2. 解析TIFF头部,获取字节序
    // 3. 找到IFD0,遍历Tag
    // 4. 当Tag == 0x8827 (ISOSpeedRatings) 时,读取值
    // 注意:ISO值可能存储在SubIFD中
    return iso_value;
}
💡 小技巧: 很多RAW文件里,EXIF数据是压缩分片存储的。比如索尼的ARW格式,EXIF可能分散在多个位置。我的习惯是:先用ExifTool扫一遍,确认所有字段的位置,再写解析代码。这样可以少走很多弯路。

2.3 图像数据载荷:真正的「货物」

跳过头部和元数据,就到了最核心的部分——图像数据。这部分就是纯粹的像素值,没有任何修饰。每个像素点只记录一个颜色通道的亮度值(R、G、B之一),这就是Bayer模式。

数据排列方式很简单:按行扫描,从上到下,从左到右。但要注意位深字节对齐

比如一个12bit的RAW,每个像素占12位。但存储时,为了对齐,可能会用16位(2字节)来存一个像素,也可能把两个12bit像素拼成3个字节。这取决于厂商的设计。

我见过最坑的一种情况:某款传感器宣称是10bit RAW,但实际数据里,每个像素占了2个字节,高6位全是0。说白了就是「假10bit」。怎么发现的?我统计了一下数据分布,发现低10位有值,高6位永远为0。嗯,这种坑,不踩一次很难记住。

// 读取12bit packed RAW数据的示例(C++)
// 假设数据是 packed 格式:每3个字节存2个像素
void unpack_raw12(const uint8_t *packed, uint16_t *unpacked, int num_pixels) {
    for (int i = 0; i < num_pixels / 2; i++) {
        uint32_t triple = (packed[0] << 16) | (packed[1] << 8) | packed[2];
        unpacked[i * 2]     = (triple >> 12) & 0xFFF;
        unpacked[i * 2 + 1] = triple & 0xFFF;
        packed += 3;
    }
}

2.4 黑电平(Black Level):传感器的「零点校准」

好,货物卸下来了。但你会发现,这些像素值并不是从0开始的。即使你把镜头盖盖上,传感器依然会输出一个非零的值。这个值就是黑电平

为什么会有黑电平?因为传感器有暗电流、有读出噪声。如果不减去这个底噪,你拍出来的黑色物体就会发灰。

黑电平参数通常存储在RAW文件的元数据里,或者头部信息中。它可能是一个全局值,也可能是每个通道独立的值(R、G1、G2、B各有不同)。

校正方法很简单:

// 黑电平校正
pixel_corrected = pixel_raw - black_level;
if (pixel_corrected < 0) pixel_corrected = 0;

你想想看,如果黑电平是512(12bit RAW),而你直接拿原始数据去显示,那画面会整体偏亮,对比度也会很差。我见过有人做ISP流水线,忘了做黑电平校正,结果整个画面的暗部细节全丢了。后来加上这一步,效果立竿见影。

🔑 关键点: 黑电平校正必须在任何增益或白平衡处理之前进行。因为增益会放大噪声,包括黑电平的噪声。先减掉底噪,再做放大,信噪比才能最大化。

2.5 白平衡(White Balance)参数:还原真实色彩

黑电平搞定之后,下一个问题来了:不同光源下,传感器对RGB三个通道的响应是不一样的。日光灯下偏蓝,白炽灯下偏黄。白平衡参数就是用来纠正这个偏差的。

白平衡参数通常以增益系数的形式存在:R_gain、G_gain、B_gain。有些传感器还会给两个绿色通道不同的增益(G1_gain、G2_gain)。

这些参数从哪里来?

  • 自动白平衡(AWB):相机在拍摄时,根据场景自动计算出来的。会写入EXIF。
  • 手动白平衡:用户设定的色温值,或者对着灰卡自定义的。
  • DNG格式:会存储ColorMatrix和AsShotNeutral,需要结合色彩校正矩阵一起使用。

应用白平衡的代码很简单:

// 白平衡校正(在Bayer域进行)
pixel_r_corrected = pixel_r_raw * r_gain;
pixel_g_corrected = pixel_g_raw * g_gain;
pixel_b_corrected = pixel_b_raw * b_gain;

但这里有个细节:白平衡增益通常是一个浮点数,而像素值是整数。直接乘完再取整,会有精度损失。我的做法是:先把增益放大到定点数(比如Q8.8格式),再乘,最后移位。这样既快又准。

⚠️ 注意: 白平衡增益不要超过某个上限(比如4.0)。否则会引入严重的色阶断层和噪声放大。我曾经在一个弱光项目中,为了强行还原色彩,把B通道增益拉到了8.0。结果蓝色通道全是噪点,画面惨不忍睹。后来我加了增益上限钳位,并配合去噪算法,才解决了问题。

2.6 小结:解析RAW的「四步走」

好了,这一章的内容就这些。我们来捋一下思路:

  1. 读头部:拿到图像尺寸、位深、Bayer排列、数据偏移。
  2. 提EXIF:获取拍摄参数,尤其是白平衡和黑电平信息。
  3. 取载荷:按正确的位深和排列方式,把像素数据读出来。
  4. 做校正:先减黑电平,再乘白平衡增益。

这四步走完,你手里的RAW数据才算「干净」了,可以进入后续的ISP流水线了。下一章,我们会聊Bayer插值(去马赛克),也就是如何把单通道的Bayer数据,变成全彩的RGB图像。

到时候见。