1. 传感器只认光子不认颜色:Bayer阵列的物理前提
之前在某次成像链路的联调里碰到一个很典型的现象:同一台设备,白天拍出来颜色正常,换成高亮补光之后整张图偏紫,边缘还爬满彩色噪点。逻辑上算法一行没改,最后定位到固件更新后 Bayer 相位从一种排列换成了另一种,而上层写死的转换常量没跟着改。RGB 与 Bayer 之间的转换看上去只是"隔行隔列取像素"这么点事,但真正落到工程里,相位、位深、stride、白平衡顺序,任意一个环节错一位,出来的就是一张颜色全错的图,而你还以为是白平衡没调好。
这篇内容想讲清楚三件事:Bayer 这个格式到底为什么长这样、RGB 转 Bayer(mosaic)和 Bayer 转 RGB(去马赛克)各自在什么场景下会被用到、以及这两条链路上真正会咬人的细节都在哪里。适合正在写 ISP 相关代码、做 RAW 数据仿真、或者要给降噪与去马赛克模型造训练数据的人看。代码部分全部用 numpy 和常用图像库实现,可以直接复现,不依赖任何私有工具链。
1.1 CFA 与四种相位的命名规则
图像传感器的每个感光单元本质上只测量一件事:在一定曝光时间内落到它上面的光子数量。它没有能力区分这个光子是 650nm 的红光还是 450nm 的蓝光。要想得到颜色,只有两条路:要么在像素上叠三层滤光片分别测三个通道(三层堆叠式传感器,成本和工艺复杂度都高),要么在每个像素前面只放一种颜色的滤光片,让相邻像素分别负责红、绿、蓝,最后靠算法把缺失的两个通道"猜"回来。
后者就是彩色滤光阵列(Color Filter Array,CFA),Bayer 阵列是其中最经典的一种。它把像素按 2×2 为基本单元排布,每个单元里两个绿、一个红、一个蓝。以最常见的 RGGB 为例,左上角 2×2 块长这样:
R G R G R G G B G B G B R G R G R G G B G B G B注意这里有个非常容易混淆的点:同一块 CFA,"RGGB"和"BGGR"可能指的是同一张图。因为命名规则有两种流派——一种按左上角 2×2 块的第一行、第二行依次念出来(R-G / G-B 就叫 RGGB),另一种按第二行第二列、第二行第三列这两个元素来命名(对上面这张图来说就是 B 和 G,叫 BG)。两种规则描述的是同一张图,但名字完全不同,这是后面所有相位事故的根源。
工程上我一般不去纠结"官方怎么叫",而是统一约定:我自己的代码里,RGGB 一律指左上角像素是红色、其右邻是绿色、其下邻是绿色、右下角是蓝色。所有转换函数、配置文件、日志打印都遵循这一条,跨模块沟通时只认这个定义。这条约定看起来啰嗦,但能省掉大量"你说的 RGGB 是不是我说的 RGGB"的扯皮。
1.2 绿色翻倍背后的视见函数逻辑
为什么是绿多红蓝少,而不是三色平均分配?原因在人眼的响应曲线。人眼有三种视锥细胞,其中对中波(大致 530nm 附近)敏感的 M 型细胞数量与响应强度都占优,而亮度感知 Luma 的经验权重里,绿通道大约占 0.59,红占 0.3,蓝只占 0.11。
这就带来一个很实际的结论:人眼对亮度细节的分辨能力,绝大部分来自于绿通道。既然绿通道对"看起来清不清楚"贡献最大,那自然应该给它分配更多的采样点。Bayer 把绿做成红蓝的两倍,等于用 50% 的像素去承载约 59% 的亮度信息,剩下的 50% 像素分给色度。而人眼对色度细节的分辨力本来就弱得多(色度带宽可以大幅压缩而肉眼看不出),这笔账算下来非常划算。
顺带一个常被忽略的推论:正因为绿采样密度是红蓝的两倍,去马赛克算法的质量差异主要体现在红蓝通道上,而视觉上的"锐度感"主要由绿通道的重建质量决定。调试时如果发现图整体发软,优先看绿通道的插值核;如果发现边缘有彩边,优先看红蓝的插值策略。这个分工意识能让你少走很多弯路。
1.3 一个必须实测确认的问题:转换常量的对应关系
主流图像库都提供现成的 Bayer 转换常量,但它们的命名历史包袱很重。以某个广泛使用的计算机视觉库为例,它的COLOR_BayerXX2BGR系列常量命名规则是:XX 取的是图像第二行、第二列和第三列这两个位置的元素首字母。对 RGGB 排列来说,第二行是G B G B...,第二列是 B,第三列是 G,所以 RGGB 对应的常量名居然是以 "BG" 开头的那一个。
我第一次遇到这个对应关系时,对着文档读了半小时才确认没看错。更麻烦的是,同一个库里后来还新增了另一套命名更直观的常量,两套并存,语义并不完全一致。所以我给自己定了一条死规矩:任何新接入的相机或新拿到的 RAW 数据,绝不靠命名猜,一律用实测方法确认相位。
实测方法很简单,造一张纯色测试图(或者拿一张有明显纯色块的实物照片),把它 mosaic 成 Bayer,然后用库的四个常量各转一遍,看哪一个能把颜色还原回来。更省事的办法是直接看结果里的颜色通道是否有规律地错位——如果红色块被还原成了蓝色,说明相位错了两格,对应的常量就在另一组里。这个验证花不了五分钟,但能避免后面几天的无效排查。
2. RGB转Bayer:mosaic看起来简单,坑都在数据格式上
RGB 转 Bayer 这一步,在实际项目里的用途比很多人想的多。它不是为了"压缩"——虽然数据量确实从 3 通道降到了 1 通道——真正的用途集中在三类场景:一是传感器仿真,手上只有普通相机拍的 RGB 图,需要构造出接近真实传感器的 RAW 数据来验证 ISP 链路;二是训练数据构造,做去马赛克或 RAW 域降噪的模型时,需要成对的 (Bayer, RGB) 数据;三是链路验证,把已知的 Bayer 喂进后端,再用 mosaic 出来的结果做闭环对比,确认整条通路的相位、位深、色彩变换是否一致。
三类场景对 mosaic 的要求完全不同。仿真要求物理真实性(涉及线性化、噪声注入、位深量化),训练数据构造要求速度快和批量处理能力,链路验证要求绝对精确的逐像素对齐。混用会出问题,得先想清楚自己在做哪一类。
2.1 采样、降位与两个必须先确认的前提
最朴素的 mosaic 实现就是三行:
import numpy as np # 约定 RGGB:左上角为 R,其右为 G,其下为 G,右下为 B def rgb_to_bayer_rggb(rgb): h, w = rgb.shape[:2] assert h % 2 == 0 and w % 2 == 0, "宽高必须是偶数" bayer = np.zeros((h, w), dtype=rgb.dtype) bayer[0::2, 0::2] = rgb[0::2, 0::2, 0] # R bayer[0::2, 1::2] = rgb[0::2, 1::2, 1] # G(奇数行) bayer[1::2, 0::2] = rgb[1::2, 0::2, 1] # G(偶数行) bayer[1::2, 1::2] = rgb[1::2, 1::2, 2] # B return bayer代码本身没有难度,难度在于调用它之前的两个前提。
前提一:通道顺序。输入数组是 RGB 还是 BGR,这个必须确认。很多人是从图像读取库直接拿数组进来的,而不同库、不同版本的默认通道顺序不一样。一旦搞反,mosaic 出来的图在去马赛克后会呈现红蓝互换的效果,但由于红蓝互换在某些场景下不那么刺眼(比如拍树叶),很容易被当成白平衡问题而排查很久。稳妥的做法是:在函数入口强制接收明确标注的通道顺序,或者在调试时先 mosaic 再 demosaic,把结果和原图并列看一眼,色相明显偏移就说明通道顺序有问题。
前提二:数据是否线性。这条更隐蔽。普通相机输出的 JPEG 或 PNG 里存的是经过 gamma 编码的 sRGB 数据,而真实传感器输出的是线性响应值。如果你直接把 gamma 编码后的图 mosaic 成 Bayer 当作"仿真 RAW"喂给 ISP,那么 ISP 里的黑电平校正、白平衡增益、gamma 曲线全部会作用在错误的数据分布上,出来的结果自然对不上。
正确做法是先做 sRGB 到线性的反变换(近似用 2.2 次幂即可,或者用标准的分段公式),再 mosaic。反过来,如果你只是拿来做训练数据对,目标模型也训练在同样的数据分布上,那么不做线性化反而是对的做法。关键是让训练和推理的数据分布一致,而不是让数据"物理上正确"。这条经验我在好几个项目里都验证过:一致性的重要性高于正确性。
2.2 12bit塞进uint16:左对齐还是右对齐
位深是另一个高频事故点。传感器常见输出是 10bit、12bit、14bit,而内存里通常用uint16存储。这里有两派做法:
- 右对齐:12bit 数据的有效位在低 12 位,高 4 位为 0,取值范围 0 到 4095。
- 左对齐:12bit 数据放在高 12 位,低 4 位为 0,取值范围 0 到 65520(步长 16)。
两种做法在真实设备里都能见到。问题在于,如果你拿到的数据是左对齐的,却按右对齐去处理,会发生什么?所有像素值都会被除以 16,图像整体变成接近全黑,然后后端的自动增益把亮度拉回来,但噪声也被一起放大了 16 倍,同时高位的信息被当成低位丢弃,动态范围直接损失 4 个 bit。这种问题的表现是"图像能看,但暗部噪点异常多、高光容易死白",非常容易被误判成传感器噪声大。
判断方法很直接:取一段均匀亮区的数据,看最小值和最大值。如果最小值不是 0 附近而是十几、二十几,最大值接近 65535 而不是 4095 左右,基本可以确定是左对齐。更严谨的办法是统计像素值的低 4 位,如果全为 0,那必然是左对齐(12bit 数据左移 4 位后低 4 位恒为零)。这一条检验我每次接新相机都会跑一遍,两行代码的事:
low4 = (raw & 0x0F) print("低4位全零?", np.all(low4 == 0), "| 低4位取值种类数:", np.unique(low4).size)顺便说一句,如果你在做 RGB→Bayer 的仿真,也要考虑这个对齐问题。输入 8bit 图、目标是 12bit RAW,就得把 8bit 左移 4 位而不是左移 2 位再左移 2 位——虽然数学上等价,但中间过程的 dtype 溢出问题会让结果完全错掉。np.uint8左移 4 位会溢出回绕,这个坑我踩过不止一次,务必先把 dtype 提升到np.uint16或np.int32再移位。
2.3 奇数宽高与ROI裁剪带来的边界问题
Bayer 阵列的最小周期是 2×2,这意味着图像的宽高必须是偶数,否则边缘那一行或那一列的像素凑不成完整的 CFA 单元。宽高为奇数时,代码里用[::2]切片写入,最后一行或最后一列会只写到一个通道,其余保持为 0,形成一条黑边。如果这条黑边没被注意到,去马赛克时会在边缘产生剧烈的伪色扩散,看起来像是"边缘有一圈彩色噪点"。
比奇数尺寸更麻烦的是 ROI 裁剪。很多应用为了性能只处理画面的一个子区域,或者为了输出固定分辨率而对图像做裁边。裁剪的起始坐标必须是偶数,否则整张图的相位会被整体平移一格,原本的 RGGB 布局变成了 GRBG。这个错误极其隐蔽,因为图像本身看起来还是"正常"的,只是颜色有轻微偏移、边缘有轻微伪彩,很难直接定位。
我处理这个问题的办法是:所有裁剪操作封装成一个工具函数,在函数内部强制把起始坐标向下对齐到偶数:
def roi_crop(bayer, y0, x0, h, w): y0 -= y0 % 2 x0 -= x0 % 2 h -= h % 2 w -= w % 2 return bayer[y0:y0+h, x0:x0+w]代价是可能丢掉一行一列,换来的是相位绝对稳定。对绝大多数应用来说,这一行一列的损失完全无感,而相位错位的代价是整张图报废。
3. Bayer转RGB:去马赛克三代算法的取舍
Bayer 转 RGB 在行业里叫去马赛克(demosaic)。这个环节是整条 ISP 链路里最"算法密集"的一步,也是画质差异最明显的一步。同一个传感器的 RAW,用不同算法处理,细节保留、边缘锐度、伪彩程度、噪点表现能差出一大截。这里不打算把论文里的公式全抄一遍,而是按"算法解决了什么问题、代价是什么、什么场景该选它"的思路梳理三代方案。
3.1 双线性:基线、可复现、但两个缺陷绕不过去
双线性插值是所有去马赛克算法的起点。它的逻辑是:绿通道在 2×2 块里对角方向有两个已知值,红蓝在 2×2 块里只有一个已知值。于是绿的缺失值用上下或左右最近的两个已知绿插值,红蓝的缺失值用最近邻的四个已知同色插值。
它的优点是实现简单、可完全向量化、结果可复现、没有参数需要调。在很多对画质要求不高的场合(比如预览流、机器视觉里只做定位不做判色的场景),双线性完全够用,而且速度优势巨大。
但它有两个绕不过去的缺陷。第一个是拉链效应(zipper):在斜边上,由于插值只在水平或垂直一个方向进行,重建出来的边缘会呈现锯齿状的明暗交替,像拉链一样。原因是边缘两侧的像素被错误地混在了一起。第二个是伪彩(false color):在接近奈奎斯特频率的高频纹理区域,红蓝的采样点落在不同的相位上,插值后局部色差发生跳变,出现本不存在的彩色斑块。尤其是拍细密条纹的织物、远处的建筑窗格、分辨率测试卡时特别明显。
这两个缺陷的存在,直接催生了后两代算法。我在给新人讲去马赛克时经常说:双线性不是"错的算法",它是"没做决策的算法"。它不问这一片区域是平坦还是边缘,一律用同一套权重去插,所以一旦遇到边缘就必然出错。后面所有算法的工作,本质上都是在教程序"看情况办事"。
3.2 方向自适应与色差域:把"猜"变得更聪明
第二代思路是方向自适应。核心想法是:插值之前先估计局部梯度,如果水平方向梯度小、垂直方向梯度大,说明这里有一条近似垂直的边缘,那就沿水平方向插值,避免跨过边缘把两侧的像素混在一起。比较经典的实现是先用已知的绿通道估计水平和垂直方向的二阶差分,据此算出一个权值,再按权值加权插值绿通道。也有做法不满足于两个方向,而是取多个方向的梯度,按梯度大小排序后用前几个方向做加权,梯度越小权重越大。
这一代方法的画质提升很明显,尤其在斜边和细线条上,拉链效应能压下去一大半。代价是计算量上去了——每个像素都要算多次梯度、做多次比较,而且算法里有若干阈值参数,这些参数跟传感器的噪声水平相关,换一个传感器就得重新标定。我在实际项目里的体会是:方向自适应算法的参数调优时间,往往比实现它的时间还长。
但在绿通道处理完之后,还有一个更严重的问题没解决:红蓝通道怎么插。如果直接用已知的红蓝采样点做双线性插值,伪彩依然严重。于是第三代思路出现了——色差域插值。它的物理假设是:在局部小区域内,不同通道之间的差值(比如 R−G、B−G)是缓慢变化的,而通道本身的变化可能很剧烈。这个假设的依据是自然图像的色彩在局部通常比较均匀,剧烈变化的是亮度而非色度。
基于这个假设,正确做法是:先用高质量的算法插完绿通道,然后计算已知红蓝位置上的色差 R−G、B−G,对这些色差做插值,最后再加上对应位置的绿值还原出 R、B。这样做的效果是伪彩大幅减少,因为它把"亮度的高频变化"排除在色度插值之外了。实测下来,色差域插值相比直接插值,在高频区域的伪彩能少一个数量级,代价只是多了一次减法和一次加法。
再往后还有基于色差域的迭代修正方案:先粗插一遍,然后检测哪些位置的色差偏离邻域均值太多,判定为疑似伪彩,在这些位置做局部修正。这类方法的效果取决于检测策略,做得好能进一步压伪彩,做得激进会在真实色边的地方误伤,把正常的色彩过渡抹平。
3.3 深度学习方案在工程中的真实位置
近几年基于网络的去马赛克方案不少,思路大多是把 mosaic 后的单通道数据(通常会先把四个相位拆成四个子图,或者做零填充成三通道)送进卷积网络,直接回归出 RGB。优势是在低照度、高噪声条件下表现明显优于传统方法,因为它可以把去马赛克和降噪联合起来做,而传统链路里这两步是分开的、会互相干扰。
但在工程落地时,这类方案有几个现实约束要提前想清楚。第一是算力与延迟:移动端和嵌入式设备上,一个中等规模的网络跑 4K 分辨率,帧率很难看,通常需要做分块推理,而分块会带来块边界不一致的问题,得加重叠区域再做融合。第二是泛化性:网络在训练时见过的传感器噪声特性上表现好,换一个传感器、换一种增益,效果可能直接崩掉,所以实际部署往往需要针对每个机型单独微调,这个成本不低。第三是可解释性与调试难度:传统算法出问题可以逐级打印中间结果定位,网络的中间特征图很难对应到具体的图像问题。
我的判断是:在高端机型和低照度场景,混合方案是主流——传统算法打底保证基础画质和稳定性,网络只负责在特定条件下做增强,并且要有明确的开关和回退机制。纯网络方案在成本敏感的产品里,短期内还是很难全面替代传统链路。
4. 两条链路的numpy实现与验证
前面讲了不少原理,这一节把可跑的代码写完整。所有实现都基于 numpy,去马赛克的卷积部分用通用滤波函数,思路一样,换成别的库也能照搬。
4.1 mosaic的向量化写法与相位自检
先写一个支持四种相位的 mosaic,方便做对比实验:
import numpy as np # 相位定义:(dy, dx) -> 通道索引,通道顺序为 RGB PHASE = { 'RGGB': {(0, 0): 0, (0, 1): 1, (1, 0): 1, (1, 1): 2}, 'BGGR': {(0, 0): 2, (0, 1): 1, (1, 0): 1, (1, 1): 0}, 'GRBG': {(0, 0): 1, (0, 1): 0, (1, 0): 2, (1, 1): 1}, 'GBRG': {(0, 0): 1, (0, 1): 2, (1, 0): 0, (1, 1): 1}, } def rgb_to_bayer(rgb, pattern='RGGB'): h, w = rgb.shape[:2] assert h % 2 == 0 and w % 2 == 0 out = np.zeros((h, w), dtype=rgb.dtype) for (dy, dx), c in PHASE[pattern].items(): out[dy::2, dx::2] = rgb[dy::2, dx::2, c] return out写完立刻做一次自检,这是习惯:用一张纯红、纯绿、纯蓝各占三分之一的图跑 mosaic,再统计每种相位的采样分布,确认红蓝位置没搞反。这一步花三十秒,能挡住后面几小时的困惑。
另一个更直接的自检方法是"颜色还原对比":把 mosaic 结果用四种相位分别去马赛克,和原图算 PSNR,最高分对应的相位就是正确相位。这个方法在不知道 RAW 来源、只有一张图的场景下特别有用。
4.2 归一化卷积实现双线性去马赛克
双线性去马赛克有个很优雅的向量化写法,叫归一化卷积:把已知像素和"是否已知"的掩码分别做同样的卷积,然后相除。这样每个输出像素都是它邻域内已知同色像素的加权平均,权重由卷积核决定,不需要写任何条件分支。
import numpy as np import cv2 def bayer_to_rgb_bilinear(bayer, pattern='RGGB'): h, w = bayer.shape # 绿通道用 5x5 核更准(四邻域 + 对角),红蓝用 3x3 k_g = np.array([[0,0,1,0,0], [0,2,4,2,0], [1,4,8,4,1], [0,2,4,2,0], [0,0,1,0,0]], np.float32) k_rb = np.array([[1,2,1], [2,4,2], [1,2,1]], np.float32) k_g /= k_g.sum(); k_rb /= k_rb.sum() val = np.zeros((h, w, 3), np.float32) msk = np.zeros((h, w, 3), np.float32) for (dy, dx), c in PHASE[pattern].items(): val[dy::2, dx::2, c] = bayer[dy::2, dx::2] msk[dy::2, dx::2, c] = 1.0 out = np.zeros_like(val) for c in range(3): k = k_g if c == 1 else k_rb num = cv2.filter2D(val[..., c], -1, k, borderType=cv2.BORDER_REFLECT) den = cv2.filter2D(msk[..., c], -1, k, borderType=cv2.BORDER_REFLECT) out[..., c] = num / np.maximum(den, 1e-8) return np.clip(out, 0, None)这个写法的好处是核可以随意换:想要更强的平滑就放大核,想要更锐就缩小核,甚至可以把前面讲的方向自适应的权重塞进核里做近似。边界处理用镜像填充,比补零能明显减少边缘的亮度塌陷——如果不做镜像填充而是补零,边缘一圈的归一化分母会偏小,导致边缘被异常提亮,形成一圈亮边。
4.3 相位校验与PSNR评测的正确姿势
评测去马赛克质量时,直接算 PSNR 有两个陷阱。第一个陷阱是边界效应:无论用哪种填充方式,图像最外一圈的插值都不准,会拉低整体 PSNR。标准做法是算之前先裁掉 8 到 16 像素的边界。第二个陷阱是位深与范围的统一:一边是 0-255 的整数,一边是 0-1 的浮点,混着算出来的 PSNR 毫无意义。我一般统一转成 float32、范围 0-255 再算。
def psnr(a, b, border=8, peak=255.0): a = a[border:-border, border:-border].astype(np.float32) b = b[border:-border, border:-border].astype(np.float32) mse = np.mean((a - b) ** 2) return 10 * np.log10(peak ** 2 / max(mse, 1e-12))经验参考值:双线性去马赛克在常规自然图像上,PSNR 通常在 30 到 34 dB 之间;方向自适应加色差域能到 36 到 40 dB;再往上就要靠专门的迭代修正或网络了。但这个数字不能用来横向比较不同实现,因为它高度依赖测试图的内容——高频纹理多的图 PSNR 天然低,平滑的图天然高。只有在同一批图上比较不同算法才有意义,跨批次比较没有参考价值。
还有一个特别值得做的验证:把去马赛克结果再 mosaic 回 Bayer,和原始 Bayer 做逐像素比对。正常情况下这个差异应该极小(只在插值不精确的位置有微小偏差),如果差异很大,说明两边的相位定义不一致,或者某一步动了数据。我把它叫做"往返一致性检查",是排查相位问题最快的手段。
5. 我踩过的几个具体坑
原理和代码都清楚了,剩下的问题基本都在工程细节上。下面这几个是我印象最深的,写出来供参考。
5.1 stride对齐导致的"斜纹错位"
有些设备驱动的输出缓冲区每行不是紧排的,而是按 16 或 64 字节对齐,行尾有填充字节。如果你按"每行宽度等于图像宽度"去解析,从第二行开始就会整体错位,最终效果是整张图呈现斜向的条纹状错位,而且越往下偏得越多。
判断方法很简单:如果图像内容有周期性的斜向错位、并且错位量随行号线性增长,基本可以确定是 stride 没处理对。修复方式是按设备给出的 stride 值构造视图,或者把填充字节切掉再重排:
# rows: 每行实际字节数(含padding),w: 有效像素数,dtype: 像素类型 valid = np.frombuffer(buf, dtype=dtype).reshape(h, stride_in_pixels) img = valid[:, :w].copy()这个问题的迷惑性在于,图像本身依然"有内容",不是完全乱的,很容易被当成传感器时序问题去查。我第一次遇到时在硬件侧排查了两天,最后发现是软件解析漏了 padding。
5.2 处理顺序:黑电平、白平衡、gamma谁先谁后
这个顺序错了,颜色一定会偏,而且偏得很"自然",看不出是 bug。
正确的顺序是:黑电平校正 → 镜头阴影校正 → 白平衡增益 → 去马赛克 → 色彩校正矩阵 → gamma → 其他后处理。
黑电平必须在最前面,因为它是一个加性偏置,如果不先减掉,后面的增益(白平衡、色彩矩阵)会把偏置一起放大,而且不同通道的偏置不同会导致色偏。白平衡必须在去马赛克之前做,因为去马赛克的色差域插值依赖于各通道之间的相对关系,如果先插值再乘增益,插值时用的色差假设就被破坏了。gamma 必须在最后,因为它是一个非线性压缩,一旦做了 gamma,后面的所有线性操作都失去物理意义。
有一个特别常见的错误是在 Bayer 域直接做 gamma,理由是"看起来和 RGB 域做 gamma 差不多"。实测下来差异不小:在 Bayer 域做 gamma 会让各通道的噪声分布变得不一致,去马赛克时绿通道和红蓝通道的噪声被不同的非线性曲线拉伸,结果就是暗部出现彩色噪点。这个现象在低照度下尤其明显。
5.3 PSNR很高但图不能用:指标失效的三种情况
我见过好几次 PSNR 指标很漂亮但肉眼一看就不对的情况,总结下来有三种。
第一种是评价图选得不好。如果测试集里全是平滑的自然图像,那么即使算法在边缘处表现很差,平均 PSNR 也不会低多少,因为边缘像素占比小。解决办法是必须加入高纹理密度的测试图(分辨率卡、织物、密集窗格),并且分区域统计。
第二种是结构指标和感知指标不一致。PSNR 只看逐像素误差,而人眼对结构、边缘、色彩的一致性更敏感。一张图 PSNR 高但边缘发糊、色彩闷,PSNR 是看不出来的。实践中我会同时看结构相似性指标和色差指标,必要时直接人工比对局部放大图。
第三种最隐蔽:评测数据本身有问题。比如原图和重建图的色彩空间不一致(一个 sRGB 一个线性),或者一个做了锐化一个没做,PSNR 依然能算出个数字来,但完全没有意义。所以评测脚本的第一件事应该是断言两边的形状、dtype、值域、通道顺序完全一致,不一致就直接报错,不要让它默默算下去。
5.4 高频纹理下的摩尔纹,为什么调参调不掉
拍细密条纹(比如衬衫面料、远处的百叶窗、屏幕)时出现的彩色摩尔纹,是很多人想靠调去马赛克参数解决但解决不了的问题。原因在于它不是算法的问题,是采样定理的问题。
红蓝通道的采样率只有总像素数的四分之一,对应的奈奎斯特频率也只有一半。当场景中的空间频率超过这个上限时,就会发生混叠,产生低频的假信号,这就是摩尔纹。任何插值算法都无法从已经混叠的数据中恢复原始信息——它在数学上是不可逆的。
工程上的应对手段都在采样之前:一是在传感器前加光学低通滤波(会牺牲一点锐度),二是用更高分辨率的传感器(把奈奎斯特频率推到场景频率之上),三是后期做专门的摩尔纹抑制(本质是频域陷波,会有副作用)。我在项目里的做法是在 ISP 链路里加一级轻量的高频抑制,宁可损失一点点极限锐度,也要把摩尔纹压到不明显,因为摩尔纹在视觉上比轻微发软更刺眼。
顺带说一个经验:判断一张图里的彩色斑块是伪彩还是摩尔纹,看它是否随拍摄距离或角度变化而改变形状。伪彩跟着像素位置走,位置固定;摩尔纹跟着场景的空间频率走,稍微改变距离图案就会大幅变化。这个区别决定了你该去改算法还是该去改光学。
6. 一份我自己的检查清单
每次接入新的 RAW 数据源或者新写一段转换代码,我会按这个顺序过一遍,基本能挡住 90% 的问题。
| 检查项 | 判断方法 | 出问题的典型表现 |
|---|---|---|
| 相位是否正确 | 四种相位各转一遍,比 PSNR | 红蓝互换、局部色偏 |
| 位深对齐方式 | 看低 4 位是否全零 | 暗部噪点多、高光死白 |
| 通道顺序 | mosaic→demosaic 往返对比 | 红蓝整体互换 |
| stride 处理 | 检查行错位是否随行号线性增长 | 斜向条纹错位 |
| 尺寸奇偶 | 检查宽高是否都能被 2 整除 | 边缘一条黑边、边缘伪彩 |
| 黑电平是否已减 | 看暗场均值是否接近 0 | 整体发灰、暗部色偏 |
| 处理顺序 | 检查 gamma 是否在最后 | 暗部彩色噪点 |
| 数据是否线性 | 确认输入是线性还是 gamma 编码 | 对比度异常、颜色发闷 |
最后再分享一个我自己觉得最省时间的习惯:永远先做小尺寸验证,再做全图。拿 64×64 的裁剪块(起始坐标对齐到偶数)跑完整链路,把中间每一步的结果都存成图看一遍,确认没问题再放大到全分辨率。全图跑一次可能几十秒,小图跑一次几百毫秒,而且小图更容易肉眼发现问题。这个习惯让我在很多次调试里把定位时间从半天压缩到了十几分钟。