
干这行快十年了跟raw图打交道的时间超过八年。这期间踩过的坑、翻过的车比很多教程里写的“标准流程”要丰富得多。今天把这几年积累的东西整理成一篇长文专门聊清楚raw图的存储格式和读取方式。先说说我为什么会写这个话题。做图像算法、相机调试、ISP开发的人肯定有体会手里拿到一张raw图第一件事是搞明白它是什么格式、怎么解析。但raw这个词本身就有歧义——可能是单反RAW文件、sensor dump出来的裸数据、甚至Windows下U盘变成的“RAW格式”。如果你搜了一圈资料发现越看越乱那这篇文章就是写给你的。我会从存储格式的分类讲起再拆解不同格式的读取方法最后分享一些平时文档里不会写的排查经验。无论你是刚入门的学生、做嵌入式相机驱动的工程师还是搞图像后期处理的爱好者都能在这篇文章里找到对应的部分。1. 先搞清楚你手里的“raw图”到底属于哪一种1.1 相机RAW文件与sensor裸数据不是一回事很多人第一次接触raw图是从单反相机开始的。相机拍出来的RAW文件比如佳能的CR2、尼康的NEF、索尼的ARW这确实是一种“raw图”。但从工程角度来说这类文件已经是“封装好的RAW”里面除了传感器原始像素数据之外还带了大量元数据相机型号、镜头参数、白平衡设置、色彩矩阵、缩略图等等。它的格式通常基于TIFF结构有一套完整的规范。另一类raw图则非常“裸”——直接就是图像传感器输出的原始像素数据通常以.bin、.raw或者没有任何扩展名的方式存在。这种数据往往是从sensor直接dump出来的或者是摄像头模组在调试阶段导出的。它没有统一的文件头位深可能是8bit、10bit、12bit或16bit像素排列方式可能是RGGB、BGGR、GBRG或者GRBG。读取这类数据完全依赖你手动指定参数。这两类raw图用到的读取工具和流程完全不一样。如果你拿着一份纯sensor裸数据却去打开LibRaw想让它自动解析那大概率会失败——因为LibRaw是给封装好的RAW文件用的它不认识裸数据。反过来如果你拿到CR2却按裸数据的办法用numpy硬读也只会得到一堆错乱的数据。所以第一步永远是搞清楚你手里的raw图是“封装格式”还是“裸数据”。1.2 封装格式与裸数据影响你选择读取工具封装格式和裸数据的本质区别在于“是否有明确的描述信息”。封装好的RAW文件自带一份“说明书”告诉你宽多少、高多少、多少位深、Bayer排列是什么。删掉这些信息剩下的才是真正的raw像素数据。而裸数据没有说明书宽高、位深、排列方式全靠你事先知道或者按约定好的格式去猜。这对读取方式的影响是决定性的。封装格式可以用现成库通用解析——rawpy、LibRaw、dcraw都可以。你用rawpy去读DNG文件几行代码就能拿到raw_image、raw_pattern这些关键字段不需要关心TIFF的复杂结构。但裸数据就不同了你不能指望现成库认识一种没有任何标识的二进制文件。你需要自己写解析函数按宽度、高度、位深把数据切出来还要自己跳过可能存在的文件头区。现实中很多视频接口、USB摄像头给出的raw数据干脆是带自定义头部的裸数据。比如某些MIPI接口的sensor dump前64个字节可能是时间戳和配置信息。这需要你先确认头部长度、像素尺寸、存储位宽然后再把有效数据读出来。我的习惯是拿到任何来源未知的raw图先打开十六进制编辑器看一眼前面几个字节——很多格式的头部信息是有迹可循的或者至少能看出数据不是从偏移0直接开始的。1.3 常见RAW存储格式速览我在开发中经常遇到的raw图格式大致可以分成下面几类。第一类是数码相机厂商的RAW格式比如佳能的CR2/CR3、尼康的NEF、索尼的ARW、富士的RAF、松下/奥巴斯的RW2。这些格式各有各的标签结构但普遍基于TIFF/EXIF规范设计文件开头能找到标准的TIFF头“II*\0”或“MM*\0”所以工具链比较成熟dcraw能覆盖绝大多数机型。第二类是Adobe推出的DNG格式。DNG本质上是TIFF的一个扩展好处是公开、跨平台、规范明确。越来越多手机和相机直接输出DNG比如iPhone的ProRAW、一些Android旗舰的RAW模式底层就是DNG。DNG的优势在于它把Raw数据布局、CFA图案、白平衡、色彩信息都做了标准化定义是调试和交换raw图的首选格式。第三类就是纯sensor裸数据我习惯叫它raw binary。常见后缀是.raw、.bin、.bayer、.packed也可能什么后缀都没有。这类数据没有统一标准唯一能依赖的就是数据本身的排列规律。比如12bit的MIPI raw格式有packed方式每3个字节存2个像素和unpacked方式每个像素占2字节读取方法完全不同。搞清楚这三大类后面就好办了。我的经验是先看文件大小和分辨率之间的关系再打开文件头确认。以一张4000x3000的12bit raw图为例如果看得到的文件大小是18,000,000字节4000×3000×1.5说明是packed存储如果是24,000,000字节4000×3000×2那很可能做了16bit容器装12bit数据或者干脆是16bit raw。就凭文件大小你能反推出很多信息。2. 存储格式的底层细节位深、Bayer排列与头部信息2.1 每个像素只记录一种颜色Bayer排列要理解raw图为什么长成那样先得弄明白图像传感器的工作原理。绝大多数CMOS传感器前面覆盖着一层彩色滤光片阵列也就是常说的CFA。最常见的CFA就是Bayer阵列——让每个像素只感知R、G、B中的某一种颜色。这样传感器输出的数据就不是一张完整的彩色图像而是一张“马赛克”式的灰度数据。Bayer排列具体有RGGB、BGGR、GBRG、GRBG四种。以RGGB为例它的意思是2x2像素块中左上角是R、右边是G、下面是G、右下是B。为什么G占两个位置呢因为人眼对绿色更敏感为了让亮度分辨率更高传感器让绿色像素占了大多数。这点在读raw图时很关键——如果你把RGGB的数据当成BGGR去解析虽然图像不会变成完全没用的东西但颜色会错乱得很奇怪。不同厂商默认的Bayer排列各不一样。同一个sensor在不同驱动配置下输出的起点也可能会裁剪掉几行几列导致排列平移。我处理过一款模组Driver IC默认从第4行第4列开始输出有效数据如果忽略这个偏移你按sensor datasheet里写的标准RGGB去解析实际像素排列就会错位。所以在读取raw图之前建议先确认好排列信息别想当然按默认的RGGB来。万一排查半天颜色不对最后发现就是差了这一个字符。除了Bayer排列还有RGBW、RCCC、RCCB等特殊的CFA布局常见于安防监控和车载摄像头。这类raw数据用普通彩色显示算法是没法看的需要根据CFA类型重新做映射。读这类数据首先要确保你能拿到CFA布局的定义而不是盲目地用RGGB套路去解。2.2 位深与字节序人在一个字节面前是斗不过机器的raw图里每个像素占多少位直接用“位深”这个参数表示。常见的有8bit、10bit、12bit、14bit、16bit。一方面位深决定了画面的亮度分辨能力另一方面它直接影响存储空间和读取的复杂度。8bit最简单一个像素占一个字节数据排列就是一行接一行。16bit也很常见但这时候必须注意字节序问题高位在前大端还是低位在前小端。x86、ARM平台普遍用小端存储而某些传感器和嵌入式平台会输出大端数据。很多刚入门的同学踩过的第一个坑就是把大端16bit数据用小端方式读结果得到像噪点一样的乱码或者图像明暗完全错乱。10bit和12bit的数据最麻烦。如果每个像素独占两个字节来存那浪费的空间很大——12bit数据只有4096个灰度级却用了16bit的容器。为了节省存储空间很多sensor采用packed方式存储12bit的数据按每3个字节装2个像素的方式打包10bit的数据按每5个字节装4个像素。这种方式不按字节对齐读取时必须做位运算拆包。我在项目里写过一个很常用的12bit unpack函数效果就是每3字节拆成两个12bit数。import numpy as np def unpack_12bit_packed(raw_bytes): n_bytes len(raw_bytes) n_pixels n_bytes * 2 // 3 out np.zeros(n_pixels, dtypenp.uint16) for i in range(n_pixels): byte_idx i * 3 // 2 if i % 2 0: out[i] ((raw_bytes[byte_idx] 4) | ((raw_bytes[byte_idx 1] 4) 0x0F)) else: out[i] (((raw_bytes[byte_idx] 0x0F) 8) | raw_bytes[byte_idx 1]) return out大部分10bit pack则是把4个10bit像素放进5个字节。拿到这类数据先别急着转成数组先理清pack规则不然读出来的数据全是乱的。不过说实话我自己在性能敏感场景下更喜欢用numpy的位运算整批处理而不是在Python里写for循环。上面这段代码是帮助理解的实际工程里我会先转成dtype为uint8的numpy数组再用向量化操作来做拆包效率能差几十倍。2.3 头部信息与metadataDNG/CR2/NEF怎么组织纯裸数据没有头部但封装格式的RAW文件一定有头部信息。以TIFF结构为基础的RAW文件开头通常是8字节的TIFF头前两个字节是字节序标记“II”代表小端“MM”代表大端后面两个字节是魔法数42再后面4字节指向第一个IFDImage File Directory。IFD里就是一堆标签记录图像宽度、高度、位深、压缩方式、CFA图案、白平衡参数等。CR2在TIFF的基础上增加了一个自己的数据块里面包含了raw数据在文件中的偏移量。NEF的头部有厂商自定义的标签用来记录sensor元数据和Nikon特有的处理参数。DNG则把这些规范整合得更清晰同时增加了DNG特有的标签比如DNGVersion、CFARepeatPatternDim、CFAPattern这些。你如果用十六进制工具打开DNG文件能看到DNGVersion这个ASCII字符后面跟的版本号。对于开发者来说这些头部信息并不需要自己逐个去解码因为LibRaw等工具库已经把这些标签解析后暴露成结构体了。但知道它们的存在很重要一是因为某些自定义raw文件借鉴了类似的头部结构你写解析器的时候有参考二是在排查问题时用十六进制查看器看文件尾部和中部是否存在缩略图数据、是否有多个IFD能帮你判断文件到底是不是一个标准的RAW文件。3. 读取方式从代码层面把raw图正确读进来3.1 读取DNG/相机RAWrawpy与LibRaw的正确姿势要说调用现成工具读取封装RAW文件我首推rawpy。这个库就是把LibRaw包了一层Python封装用起来非常方便。安装很简单pip install rawpy 就行。下面是我经常用的读取DNG并拿到Bayer数据的示例import rawpy raw rawpy.imread(example.dng) # 原始Bayer数据形状为 (height, width) bayer raw.raw_image # CFA图案信息raw_pattern返回的是一个2x2的数组 pattern raw.raw_pattern print(pattern) # 黑电平 black_level raw.black_level_per_channel print(black_level) # 白电平 white_level raw.white_level # 后处理成可视彩色图内部会执行插值、白平衡、gamma校正 rgb raw.postprocess(use_camera_wbTrue)读DNG的时候raw.raw_image直接就是原始的Bayer数据不需要自己做TIFF解析。raw.raw_pattern返回的是2x2的CFA排列结合raw.sizes拿到宽高就能确定实际的Bayer布局。raw.black_level_per_channel返回每个通道的黑电平这在做算法时特别有用因为不同通道的黑电平不一定相同。对于CR2、NEF、ARW这类厂商格式rawpy/LibRaw同样支持。有一个注意点rawpy读大文件时内存占用会比较高一张几千万像素的14bit RAW文件raw_image本身可能就是几百MB级别。如果机器内存紧张可以考虑分段读取或者换成C调用LibRaw库来降低开销。另外rawpy的imread会一次性加载全部数据不适合用在超大分辨率或者流式处理的场景里。3.2 读取纯裸数据numpy直接按字节读多数裸数据没有文件头所以读取逻辑比较简单但容易出错宽度、高度、位深、排列、起始偏移这五个参数缺一不可。我通常会先看文件大小能不能整除宽×高如果不能就考虑是否有文件头或者是否存在行对齐。以一张16bit裸数据为例假设宽度是1920、高度是1080文件总大小是4,147,200字节1920×1080×2。读取代码如下import numpy as np width, height 1920, 1080 header_size 0 with open(sensor_raw.raw, rb) as f: if header_size: f.seek(header_size) # 如果文件本身就是小端直接读uint16再用reshape data np.fromfile(f, dtypenp.uint16, countwidth * height) bayer data.reshape(height, width)如果文件还带一个128字节的头部那要先f.seek(header_size)再读图像数据。这里有个容易被忽视的点np.fromfile读取的时候会按照当前平台的字节序解释数据。在x86/ARM平台上默认是小端但如果raw文件是大端你需要用dtype和byteswap处理一下data np.fromfile(f, dtypenp.uint16, countwidth * height).astype(u2) # 如果原始是大端可以这样转小端 data data.byteswap()10bit或12bit的packed裸数据先按uint8读进来再按pack规则拆包。需要注意的是拆包后得到的数值范围可能不是0-1023或0-4095的完整范围因为很多sensor有效位虽然标称10bit实则可能只用了高10位导致最低位一直是0。这点会对直方图分析和黑电平标定产生影响后面我会详细说。3.3 前置操作黑电平扣除与白平衡归一化raw图读出来以后不能直接当成普通图像用。因为sensor在不感光的情况下输出的也不是0而是有一个本底值也就是黑电平。比如一个12bit raw图的白电平是4095黑电平可能是64那么实际有效动态范围是64到4095。算法处理前要先扣除黑电平否则画面会整体发灰暗部噪点也会不真实地偏高。黑电平的扣除方式有两种。一种是整幅图用一个固定值简单粗暴但够用另一种是按通道分别扣因为R、G、B通道的黑电平可能不一样。rawpy里读出来的black_level_per_channel就是每个通道各自的黑电平用的时候按通道对应地减掉。bayer bayer.astype(np.float32) black_levels [64, 64, 64, 64] # R,G1,G2,B的顺序按CFA排列调整 # 简化处理先把2x2的pattern展平到四个通道 # 按像素位置取对应通道黑电平并扣除扣完黑电平如果是要做显示或者专业算法通常还需要做白平衡归一化。raw图原始数据中红蓝通道的响应通常比绿色弱所以直接可视化会偏绿。这一点在调试时经常会让人疑惑——raw数据偏绿不是读错了而是正常的。只有乘上白平衡增益让灰卡在三个通道上数值一致色彩才正常。很多初学者读出一张偏绿的图以为是自己代码写错了其实只要做一步白平衡校正颜色就回来了。以上只是图像处理的开端。从raw数据到最终可用的彩色图像中间还有demosaic去马赛克、色彩校正、gamma映射等步骤这些内容是ISP的领域都可以单独写几篇长文。这里先点到为止接下来聚焦在读取验证上。4. 实操如何验证你读出来的raw图是对的4.1 用十六进制查看器核对文件头很多时候问题不出在代码逻辑上而是数据本身就不是你期望的那种raw。所以我拿到一个新样本第一步永远是打开十六进制编辑器Linux用xxd、Windows用010 Editor或者HxD看文件头。如果文件以“II*\0”开头说明这是一个小端的TIFF结构RAW文件后面通常会跟着类似CR2、NEF或DNG的标识。对于DNG你会在文件头部附近看到“DNG”的关键字对于CR2可能在偏移位置找到“CR”字符。看到这些就可以放心交给rawpy/LibRaw解析。如果文件头部是一堆看不出含义的二进制数据前面100个字节里面既没有TIFF头也没有常见ASCII标识那大概率就是裸数据。此时利用文件大小和已知分辨率推位深和pack方式再结合数据分布规律来判断读取参数是否正确。比如一张1920x1080的图像如果看到文件头部的数据有循环规律可能说明有文件头或者有时间戳等信息。我还会用命令行的hexdump配合head命令快速看前64字节效率比打开GUI工具高得多xxd -l 64 sensor_raw.raw这样能看到前64字节的内容。如果前几个字节都是00后面突然出现连续变化的数值说明这个raw图可能带了几十字节的头部读取时得跳过。4.2 把raw数据转成可视化预览与直方图验证raw数据是否正确最直观的方法就是把它变成看得见的图像。对raw数据本身我通常会做两步。第一步是把Bayer数据按照通道拆开分别输出R、G1、G2、B四个通道的灰度图第二步是对整个Bayer做简单的双线性插值得到彩色预览图方便肉眼判断颜色与细节。显示16bit数据时不能直接把0-65535范围的数值当成8bit的0-255输出否则在屏幕上会显得过暗或全黑。要先把数据做归一化比如根据白电平和黑电平缩放到0-1再映射到0-255。下面是简化示例import cv2 import numpy as np def raw_to_8bit(raw, black64, white4095): raw np.clip((raw.astype(np.float32) - black) / (white - black), 0, 1) return (raw * 255).astype(np.uint8) img8 raw_to_8bit(bayer) # 如果直接用OpenCV存图注意它只认8bit/16bit的BGR数据 cv2.imwrite(preview.png, img8)除了转8bit我还习惯打印一下数据分布比如最小值、最大值、均值、直方图峰值位置。如果最小值不是黑电平附近、最大值顶到白电平或者均值偏离太大说明数据可能有问题。比如一张被拉伸过的raw图直方图会出现梳状间隙这也是识别异常数据的一个信号。如果raw图像数据本身没毛病此时候调白平衡、demosaic这些只是显示层的事。要确认读取参数宽高、排列是否正确可以看边缘结构或灰度过渡是否自然。比如把Bayer数据按RGGB错位排列颜色会变得错乱宽高反了图像会出现明显的拉伸变形行对齐不对则会出现周期性撕裂或斜条纹。4.3 用低级镜像工具的思想做整文件检查在文件系统领域有一个工具叫HDD Raw Copy Tool专门用来对硬盘或分区做底层扇区级复制绕过文件系统逻辑直接按字节搬运数据。我在读取raw图的时候也会借鉴这种思路有时候不要依赖预览软件或现成库直接用底层方式把文件里的字节按照某种规则导出来看反而更容易发现问题。比如你要确认一个raw文件里面除了像素数据之外是不是还塞了一张缩略图。用ImageMagick或者Python的Pillow打开整个文件是没用的但你在十六进制查看器里搜索JPEG的头部标记FFD8FF很快就能找到缩略图的位置。如果搜到两张JPEG缩略图基本可以判断这是封装好的RAW文件而不是纯裸数据。还有一种场景调试MIPI摄像头时驱动一帧一帧地写入sensor数据某一帧的头信息和数据长度不对齐。这种问题靠肉眼看不出来但用脚本逐帧解析比对每帧的起始位置和数据长度与预期是否一致很快就能定位是驱动配置错位还是sensor输出的行尺寸和sensor实际尺寸不一致。这种方法就像做扇区级检查一样不依赖上层逻辑直接从原始字节上找规律。5. 常见问题与排查技巧实录5.1 读出来一团黑或一片白字节序与位深不对读raw图最经典的问题就是数据读出来要么是黑的、要么是白的或者像花屏一样全是条纹。这时候先别怀疑sensor坏了大概率是字节序或位深处理出了问题。如果是16bit数据你用大端方式存却在小端机器上直接按uint16读那一对相邻字节会被交换图像看起来会是密密麻麻的亮暗噪点或者某些区域偏色严重。解决方法是先判断文件的字节序打开十六进制查看器找一组已知明暗变化的图像数据如果相邻两个字节高低位顺序和人眼预期相反就做个byteswap试试。位深混淆也是常事。比如数据其实是12bit unpacked每个像素占2字节你却按16bit读表面看起来也没问题——因为低4位可能补0了。但如果你按8bit读图像就会变成原来高度一半的、明显的横向条纹。这种问题是可以通过文件大小除以分辨率来判断位深的建议务必做一步估算。直接给个排查顺序文件大小 ÷ 宽 ÷ 高 每像素字节数。如果算出来是1可能是8bit是2可能是16bit或12bit/10bit unpacked是1.5大概率是12bit packed是1.25大概率是10bit packed。先按这个推断再写代码能省很多无头绪的调试。5.2 彩色条纹/噪点行对齐、padding与分辨率不匹配有时候raw数据看着能辨认轮廓但会出现斜向或周期性的条纹。这类问题通常是行对齐或padding没处理。很多感光芯片输出的每行像素数并不是整数个字节对齐的驱动会在每行末尾填充若干个字节使下一行从对齐的地址开始。解析时必须把这些padding跳过。举个例子宽度为1232像素的8bit raw图一行就是1232字节1232刚好能被4整除不用padding。但宽度若是1000像素8bit一行1000字节如果硬件要求4字节对齐那实际每行存储可能是1000字节加3字节padding或者1000字节加4字节尾部校准。这种情况你直接按连续排列读取图像宽度就会随时间累积出越来越大的偏移表现出来就是斜向撕裂。处理方式是我一般会在读取前先确认行跨度stride是多少。有的驱动会在驱动配置里暴露stride如果没有就通过一个已知图像文件反推实际文件大小除以高度就是每行的真实字节数。这个值和“宽度×每像素字节数”的差值就是padding大小。分辨率不匹配也会造成类似现象。如果你把1920宽的数据当成1600宽来读图像会呈现被压扁/拉宽的样子但彩色条纹的周期通常比较整齐。结合文件大小和预期分辨率反推基本能锁定问题。5.3 图像偏色严重Bayer排列与白平衡坑很多刚接触raw的人会发现读出来的图绿的像草一样。这太正常了raw本来就不是给人直接看的。但如果你做完白平衡之后颜色还是不对那就得检查Bayer排列。对于同一个模组你可能需要试RGGB、BGGR、GBRG、GRBG四种排列看看哪一种出来的颜色最自然最符合场景。还有一种情况是CFA起始位置偏移。有些sensor支持裁剪输出或ROI输出裁剪后输出的第一个像素可能不再对应标准的RGGB排序中的R而是G、B或者别的。有些驱动会在寄存器里配置Bayer phase但驱动没设对这时图像的颜色就会出现局部偏色/花屏现象。经验是先用灰卡或均匀光照下拍一张raw图统计R、G、B各通道均值理论上如果Bayer排列正确且白平衡接近1:1:1那么三个通道的均值会比较接近如果哪个通道明显偏低偏高排列很可能错了。5.4 同名误区U盘“RAW格式无法格式化”与图像raw的区分搜索“raw图存储格式”时经常会蹭到另一类完全不同的“RAW”——Windows磁盘管理里显示为“RAW”的U盘或硬盘分区。这不是图像文件格式而是文件系统层的状态分区表或文件系统结构损坏、无法被操作系统识别时系统就把这个分区标记为RAW。网上大量“u盘raw格式无法格式化”的问题本质上是文件系统修复问题跟图像raw数据一毛钱关系都没有。我见过不止一个朋友拿着一张raw图去查读取方法结果被带进了磁盘修复的坑研究半天怎么格式化U盘这完全是浪费时间。区分方法很简单如果问题是“文件打开后图像不对”那属于图像raw范畴如果问题是“U盘插上电脑提示需要格式化”那是文件系统层面的RAW分区损坏。两者只是英文同一个单词底层机制毫无关联。如果你确实遇到U盘分区变RAW的情况我建议不要急着格式化先用DiskGenius这类工具尝试找回分区表或用WinHex直接对U盘做扇区镜像备份后再操作。这和我前文提到的HDD Raw Copy Tool思路一致先低级复制再做数据恢复这样至少不会因为误操作把文件彻底弄丢。5.5 Raw仓库、raw资源等易混淆概念快速厘清除了图像raw和文件系统raw还有几个含着“raw”的名词也会干扰搜索。比如软件开发里的Nexus raw仓库、Maven的raw proxy这是包管理仓库的一种类型保存的是任意二进制文件跟raw图像完全不是一回事。再比如Android开发中经常遇到的“content://com.android.providers.downloads.documents/document/raw%3a%2fst”这种URI这是Android的DocumentsProvider用“raw”标识符来访问原始文件路径/媒体资源的也不是指图像raw。我写这篇文的最终建议是拿到raw相关任务先分类。如果是图像像素数据就按本文说的存储格式和读取方式来如果是文件系统或软件仓库果断切换知识体系不要在同一个词上钻牛角尖。技术领域同词异义的情况非常多这很正常关键是你得建立起分类的思维。我个人在实际操作中的体会是读取raw图的过程本质上就是一场与“格式约定”的博弈。你掌握的背景信息越全代码里的防御判断越多排查问题时就越有底气。最后再分享一个小技巧给任何raw解析代码写注释的时候强烈建议把“宽度、高度、位深、Bayer排列、字节序、偏移、stride”这七个参数直接写在文件头注释里。别问我为什么要强调这个——有多少次半个月后再回看自己写的解析代码完全想不起当时用的什么排列只能靠一遍遍试错这种亏我吃过太多次。希望这篇文章能让你少走一些弯路。