第一章:RAW数据读取与存储

各位同学,咱们今天正式开讲。第一章,我打算从最基础也最容易被忽略的地方说起——怎么把RAW文件从磁盘里读出来,再存成我们能处理的形式。

你可能觉得这有什么难的?不就是open一个文件,read一下嘛。嗯,我当年也是这么想的。直到有一次,我花了两天时间调试一个ISP流水线,最后发现是字节序搞反了……那感觉,真是一言难尽。

1.1 二进制RAW文件长什么样?

RAW文件,说白了就是传感器直接输出的原始数据。没有头,没有尾,没有元数据。就是一串二进制数,一个像素接一个像素地排在那里。

举个例子。一个1000x1000的12bit RAW图,文件大小就是1000 * 1000 * 12 / 8 = 1,500,000字节。注意,这里不是1,500,000个像素,而是1,500,000个字节。因为12bit不是整数个字节,所以存储的时候会有一些“坑”。

我个人习惯把RAW文件分成三类:

  • 纯裸数据:没有任何封装,直接是像素值
  • 带简单头部的RAW:比如DNG、某些厂商的私有格式
  • 打包格式:比如MIPI RAW,数据是按特定方式打包的

咱们这一章,先讲第一种。后面章节会逐步深入。

1.2 字节序:Little-Endian vs Big-Endian

这是个老生常谈的问题,但也是我见过最多人翻车的地方。

什么是字节序?简单说,就是一个多字节数据在内存里怎么排列。比如一个16bit的值0x1234:

  • Little-Endian:低字节在前,存成 0x34, 0x12
  • Big-Endian:高字节在前,存成 0x12, 0x34

你想想看,如果读错了字节序,一个暗区的像素值可能从0x0010变成0x1000,直接变成高光区域。我在项目中遇到过这种情况,当时查了半天,还以为是传感器坏了。

重要提醒:大多数PC和ARM平台都是Little-Endian。但有些工业相机、老式传感器会用Big-Endian。一定要看传感器的datasheet确认。

怎么判断?我一般先读几个像素,看看值是否在合理范围内。比如12bit RAW,像素值应该在0-4095之间。如果读出来一堆大于4095的数,那八成是字节序搞反了。

1.3 位深处理:10bit, 12bit, 14bit, 16bit

位深,就是每个像素用多少bit来表示。常见的就这四种。但存储方式各有不同。

位深 每像素字节数 存储方式 常见场景
10bit 1.25 打包或补齐16bit 手机摄像头、MIPI RAW
12bit 1.5 打包或补齐16bit 工业相机、高端手机
14bit 1.75 通常补齐16bit 单反相机、科学相机
16bit 2 直接存储 天文相机、医疗影像

这里有个关键点:10bit和12bit的数据,很多时候不是按bit位紧密打包的,而是每个像素占2个字节(16bit),高位对齐或低位对齐。为什么?因为这样读写快,CPU处理起来方便。

我曾经接手过一个项目,对方给的RAW文件是10bit打包格式,5个字节存4个像素。嗯,那个解析代码写得我头皮发麻。后面我会专门讲打包格式的处理。

1.4 Python读取RAW文件实战

好了,理论说完了,咱们直接上代码。我习惯用numpy来处理,因为后面做图像处理方便。

import numpy as np
import struct

def read_raw_16bit(filepath, width, height, endian='little'):
    """
    读取16bit RAW文件
    :param filepath: 文件路径
    :param width: 图像宽度
    :param height: 图像高度
    :param endian: 'little' 或 'big'
    :return: numpy数组,shape=(height, width)
    """
    with open(filepath, 'rb') as f:
        raw_data = f.read()
    
    # 检查文件大小
    expected_size = width * height * 2
    if len(raw_data) != expected_size:
        print(f"警告:文件大小不匹配。期望{expected_size},实际{len(raw_data)}")
    
    # 根据字节序选择dtype
    if endian == 'little':
        dtype = np.dtype('<u2')  # little-endian unsigned 16bit
    else:
        dtype = np.dtype('>u2')  # big-endian unsigned 16bit
    
    img = np.frombuffer(raw_data, dtype=dtype)
    img = img.reshape((height, width))
    
    return img

小技巧:我一般会在读取后打印前几个像素的值,快速验证字节序是否正确。比如打印img[0, :5],看看值是否在合理范围内。

对于12bit数据,如果每个像素占2个字节,那读取方式跟16bit一样。只是后面处理时要注意,有效数据只有低12位。

def read_raw_12bit_as_16bit(filepath, width, height, endian='little'):
    """
    读取12bit RAW文件(每个像素占2字节)
    """
    img = read_raw_16bit(filepath, width, height, endian)
    # 只取低12位
    img = img & 0x0FFF
    return img

如果是打包格式呢?比如10bit打包,5个字节存4个像素。这个稍微复杂一点。

def read_raw_10bit_packed(filepath, width, height):
    """
    读取10bit打包RAW文件
    5个字节存4个像素
    """
    with open(filepath, 'rb') as f:
        raw_data = f.read()
    
    # 每5个字节解出4个像素
    num_pixels = width * height
    num_groups = num_pixels // 4
    
    img = np.zeros(num_pixels, dtype=np.uint16)
    
    for i in range(num_groups):
        group = raw_data[i*5 : (i+1)*5]
        # 解包逻辑
        p0 = (group[0] << 2) | (group[1] >> 6)
        p1 = ((group[1] & 0x3F) << 4) | (group[2] >> 4)
        p2 = ((group[2] & 0x0F) << 6) | (group[3] >> 2)
        p3 = ((group[3] & 0x03) << 8) | group[4]
        
        img[i*4 : (i+1)*4] = [p0, p1, p2, p3]
    
    img = img.reshape((height, width))
    return img

注意:打包格式的解析一定要仔细核对datasheet。不同厂商的打包方式可能不同。我曾经因为少看了一行文档,导致解析出来的图像全是条纹,查了整整一天。

1.5 存储RAW数据

读取之后,我们经常需要把处理过的数据存回去。存储时要注意几点:

  • 保持原始位深:如果原始是12bit,存成16bit会浪费空间,但方便后续处理
  • 字节序一致性:存的时候要跟读取时保持一致,不然别人读你的文件会出问题
  • 文件命名规范:我习惯在文件名里带上宽高和位深信息,比如 img_1920x1080_12bit.raw
def save_raw_16bit(img, filepath, endian='little'):
    """
    保存16bit RAW文件
    """
    if endian == 'little':
        dtype = np.dtype('<u2')
    else:
        dtype = np.dtype('>u2')
    
    raw_data = img.astype(dtype).tobytes()
    
    with open(filepath, 'wb') as f:
        f.write(raw_data)

嗯,这一章的内容差不多就这些。总结一下:

  • RAW文件就是裸数据,没有头信息
  • 字节序搞错了,图像就全乱了
  • 不同位深的存储方式不同,10bit和12bit经常有坑
  • Python用numpy读取很方便,但要注意dtype和字节序

下一章,我会讲Bayer格式和去马赛克。那是ISP流水线里最核心的一步,也是我花时间最多的地方。咱们下章见。

公众号:蓝海资料掘金营,微信deep3321