
做图像信号处理仿真的人早晚会撞上一个看着简单、实际极容易翻车的问题你手里的数字图像到底是从哪儿来的又要显示到哪儿去很多同学的日常工作流是 imread 读图、滤波、imshow 看一眼结果、收工整套流程走下来获取和显示这两个环节被当成黑盒子跳过了。可一旦开始做相机成像链路仿真、ISP 算法调试或者要对接真实的显示设备你就会发现数字图像的获取与显示不是书上的过场章节——它决定了你处理的信号长什么样也决定了你精心设计的算法在真机上到底还能不能 work。这篇是信号处理仿真图像信号处理系列的第二篇沿着光线→镜头→传感器→ADC→数字图像→色彩处理→显示器→人眼这条链路往下讲。我会把每个环节背后的物理原理、仿真建模方法以及我在实际调仿真时踩过的坑都摊开讲。适合正在做图像处理仿真、ISP 算法、嵌入式视觉的同学也适合刚接触图像处理、想搞清楚图像是怎么变成数组、又是怎么从数组变回画面的的新手。1. 从连续场景到离散像素获取链路的建模1.1 一张数字图像是怎么诞生的数字图像的获取本质上是一次连续世界的离散化。场景中物体反射或发出的光先经过镜头聚焦到传感器表面传感器上每个感光单元像素把落在它上面的光子转换成电荷电荷量正比于曝光时间内累积的光能量最后 ADC 把电压转换成数字码值。这个过程可以表达成这样一个简化模型g[m, n] Q{ ∫∫ h(x−mΔ, y−nΔ) · f(x, y) dxdy }其中 f(x, y) 是场景的理想光强分布h 是光学系统的点扩散函数 PSFΔ 是像素间距Q 是量化器。这个式子看起来抽象但信息量很大传感器上每个像素看到的不是场景里某个点的值而是以该点为中心的一个邻域、经过光学模糊后的加权积分。我在仿真里经常看到的错误是拿一张已经渲染好的干净高分辨率图直接降采样跳过 PSF 建模。这样做的后果是混叠被严重低估仿真里算法效果看起来很好真机上却重影、摩尔纹一堆。真实的镜头有衍射极限、有像差传感器像素也有一定的感光面积这些天然的低通效应会在采样前滤掉一部分高频帮你抑制混叠建模时必须放进去。1.2 二维采样与混叠高频条纹为什么会变成低频接下来是采样定理。二维图像采样同样满足 Nyquist 条件要无失真重建场景的最高空间频率必须低于采样频率的一半。如果场景里有超过这个频率的图案——典型的例子是西装面料上的细密网格、远处的百叶窗——采样后这些高频成分会折叠到低频区形成摩尔纹。你没看错摩尔纹不是光源问题是采样混叠数学上就是频谱周期延拓之后发生重叠。想亲眼验证并不难生成一个径向频率逐渐增高的 chirp 图直接按较低分辨率重采样就能看到中心附近频率超过 fs/2 的区域原本应该越来越密的条纹突然开始向反方向散开变成大片错误的低频纹理。这个现象在真实拍摄里表现为远处的密集条纹变成了缓慢滚动的波纹。解决办法只有两条路提高采样率或者在采样前做严格的光学低通滤波。import numpy as np import matplotlib.pyplot as plt N 1024 y, x np.mgrid[-1:1:1j*N, -1:1:1j*N] r np.sqrt(x**2 y**2) chirp np.sin(2 * np.pi * (120 * r**2 30 * r)) # 频率随半径递增 down chirp[::4, ::4] # 等效1/4采样率 fig, axes plt.subplots(1, 2, figsize(10, 5)) axes[0].imshow(chirp, cmapgray) axes[1].imshow(down, cmapgray)第一张图中心区域原本是最密的条纹第二张图里中心却出现一圈圈倒流的摩尔纹这就是混叠。把这句话记在笔记里看到仿真图出现非预期的低频波纹先别怀疑算法检查一下你的采样率是不是不够。很多同学第一次遇到这种情况会花大量时间调试插值函数其实根子在采样定理。2. 量化比特数、灰阶级与伪轮廓的二三事2.1 ADC 量化与 SQNR 的对应关系传感器输出的模拟电压进入 ADC 后被映射成离散码值。8bit 对应 256 级、10bit 对应 1024 级、12bit 对应 4096 级。每增加 1bit灰度级翻一倍量化误差带来的噪声能量降到原来的四分之一。量化信噪比有一个工程上常用的估算公式SQNR ≈ 6.02N 1.76 dB。所以 12bit 数据相对 8bit量化噪声大约低 24dB换算成视觉感受就是暗部渐变明显更干净。很多同学不理解为什么 ISP 内部动辄 10bit、12bit最终照片却只输出 8bit JPEG。原因很简单中间处理去马赛克、降噪、色彩校正、伽马每一步都会引入噪声和舍入误差高比特流水线就是为了给这些中间计算留出精度余量。输出端再通过抖动、色彩映射把信息压回 8bit。理解了这条链你就不会把 ISP 参数里12bit raw理解成照片一定 12bit 清晰度。2.2 伪轮廓灰阶不够用时的视觉灾难当灰度级不足时最典型的视觉病灶是伪轮廓。在平滑渐变区域如果相邻两个灰阶的亮度差超过了人眼可分辨的阈值大脑就会把本该是连续过渡的区域看成一圈一圈的等高线。8bit 图上如果做过色彩拉伸比如把暗部提亮本来就稀疏的灰阶被进一步拉开伪轮廓会非常明显。仿真验证伪轮廓非常简单对一条线性渐变做不同比特数的量化对比即可grad np.tile(np.linspace(0, 1, 512, dtypenp.float32), (512, 1)) img8 (grad * 255).astype(np.uint8) # 8bit img6 np.round(grad * 255 / 4) * 4 # 等效6bit img4 np.round(grad * 255 / 16) * 16 # 等效4bit把三张图并排放大到全屏能看到 4bit 那条渐变完全变成了一道道台阶6bit 在暗部区域也开始出现带状痕迹。这就解释了为什么很多修图软件在导出 16bit PNG 时明显比 8bit 平滑——不是玄学是灰阶级数量在起作用。做医学影像或卫星图像的同学更应该重视这一点这些领域的显示器往往要求 10bit 起步就是为了避免诊断级图像的暗部出现假轮廓。2.3 对抗量化损伤抖动与噪声整形实际 ISP 和编码器里不会对量化结果简单地四舍五入常用手段是加抖动。做法是在量化前叠加一个幅度大约 1~2 个 LSB 的随机噪声然后量化。数学上这相当于把固定形态的量化误差改造成随机噪声虽然单点误差变大了但视觉上不再出现规则的伪轮廓过渡显得更自然。更高级的做法是误差扩散抖动把量化误差按权重扩散到邻近像素对印刷和显示场景尤其有效。仿真时我习惯在量化前加标准差 1 个 LSB 的高斯噪声效果稳定、实现简单。注意抖动噪声幅度不能太大否则会引入可见的颗粒感也不能太小太小等于没加。行业经验是 0.5~1.5 LSB 之间比较安全。还有一个细节抖动只对仍然要量化回低比特的场景有意义如果你的流程会保留高比特数据这一步可以省略。3. 颜色从哪儿来CFA、去马赛克与白平衡3.1 传感器天生只测亮度颜色靠滤镜猜绝大多数单芯片相机传感器每个像素位置上只能测一个光强度值并没有直接的彩色信息。为了输出彩色图像厂商在像素表面覆盖一层色彩滤波阵列CFA最经典的是拜耳阵列BGGR、RGGB 等变体用 2×2 的基本单元包含 1 个红、2 个绿、1 个蓝滤色片。绿色占一半不是偶然人眼对绿色亮度信息最敏感绿色通道的采样率天然高对整体分辨率贡献最大。读到这里你应该意识到一张拜耳 raw 图像每个像素只有一个通道值你平时看到的 RGB 图像是经过去马赛克插值之后的产物。这个环节在 ISP 里是重头戏它直接决定边缘锐度和色彩质量。仿真前不妨先想清楚一个问题你去马赛克的目标是什么是重建出与真实场景尽可能接近的线性 RGB还是输出一张看起来舒服的图两个目标对应的算法取舍差别很大。3.2 去马赛克的三档思路去马赛克就是从拜耳排列中重建出每个像素的 RGB 三通道。最简单的最近邻插值直接复制相邻同色像素速度快但边缘锯齿严重还会在高频区域产生明显的假彩色斑块。双线性插值稍微好一点但在边缘附近出现拉链状伪影。方向自适应方法是当前主流思路先估计边缘方向沿边缘方向插值而不是横跨边缘插值能显著减少伪彩和锯齿。仿真复现并不难把一张标准 RGB 测试图按拜耳位置抽取成单通道模拟 raw 数据再用不同插值算法重建计算重建结果与原图的 PSNR。同样是双线性边缘区域的 PSNR 比平滑区域低好几分放大了看全是彩色的锯齿。这也是为什么 ISP 里去马赛克往往要配合边缘检测而不是简单插值。做仿真的话建议把测试图分成平坦区、纹理区、强边缘区三类分别统计误差不然容易被整体均值掩盖局部劣化。3.3 白平衡与色彩校正让中性的灰真正变成灰去马赛克只是把缺失通道补回来颜色想准还得靠后面两步。首先是白平衡不同光源色温不同荧光灯偏绿、白炽灯偏黄同样的灰卡在不同光源下 RGB 三个通道的响应不一样。白平衡就是估计光源色温给三个通道乘以不同的增益让图像里的中性灰区域恢复为 RGB。仿真里最简单的做法是灰世界假设统计全图三通道均值算比例直接归一化。实际 ISP 会用灰卡、色温计或更聪明的自动白平衡算法。白平衡之后通常接一个色彩校正矩阵CCM把传感器光谱响应与标准观察者色度之间的偏差纠正到 sRGB 色域。CCM 本质上是一个 3×3 矩阵乘参数由标定得到。这里要提醒想仿真的同学CCM 的系数对光源敏感用 D65 光源下标定的矩阵去处理灯光场景颜色会偏得离谱。做仿真时至少要在你的测试集里包含不同色温的图像别让算法对单一光源过拟合。4. 显示链路从数字码值重新变回光4.1 显示设备的非线性特性数字图像最终要落到显示设备上。LCD/OLED 每个像素由 R、G、B 三个子像素组成驱动电路把码值转换成子像素亮度。一个反直觉的事实是显示器输出的亮度与输入码值不是线性比例而是近似幂函数关系L (d/D_max) 的 γ 次方γ 大约 2.2~2.5。CRT 时代这个特性来自物理机理现代 LCD 则是厂商主动按 γ2.2 校准模拟 CRT 的响应曲线。这意味着什么如果你把线性亮度图像直接送给显示器码值 128 对应的亮度并不是最大亮度的 50%而是大约 22%。全图看起来暗部极黑、整体硬邦邦暗部的层次全部挤在一个很窄的亮度区间里视觉上就是俗称的死黑。几乎所有刚接触伽马概念的人都会在这一步想不通为什么明明码值翻倍亮度却没有翻倍就是因为显示器的指数响应。4.2 伽马编码与解码一对互相抵消的变换为了对抗显示端的幂函数图像格式在编码端做了预失真先把线性亮度做 1/γ 次幂变换送到显示器后再做 γ 次幂两者相乘正好抵消人眼看到的整体响应恢复线性。这个 1/γ 变换就是伽马编码sRGB 标准里用的就是分段定义的曲线近似指数 1/2.2同时在暗部有一段线性区避免纯幂函数在零点斜率无穷大。sRGB 曲线的分段设计不是拍脑袋是考虑了暗部噪声和量化精度后的工程折中。我强烈建议你在仿真里亲手跑一遍这个链路import numpy as np def srgb_encode(img): sRGB 编码线性光 - 非线性码值输入输出均在 [0,1] a 0.055 return np.where(img 0.0031308, 12.92 * img, (1 a) * np.power(np.maximum(img, 1e-8), 1 / 2.4) - a) def srgb_decode(img): sRGB 解码非线性码值 - 线性光输入输出均在 [0,1] a 0.055 return np.where(img 0.04045, img / 12.92, np.power((img a) / (1 a), 2.4))让一个线性渐变图分别走不编码直接显示和编码再解码显示两条路径检查暗部 0~0.1 这一段。不编码时码值 128 以下的区域全部被压缩到整个亮度范围的前 22%量化台阶特别粗编码后同样的码值范围给暗部分配了更多采样点暗部细节明显更丰富。这就是为什么一切标准图像格式都要做传输函数编码——不是多余是在保护暗部。4.3 色域与色深sRGB、广色域与显示器的色阶断层显示环节还有两个容易踩的坑一个是色域一个是色深。sRGB/Rec.709 定义的色域只覆盖人眼可感知颜色的一部分广色域显示器覆盖范围更大但如果你把按 sRGB 编码的图直接放到广色域屏上不加色彩管理看起来就会过度饱和、颜色发艳。仿真阶段至少要知道图像里的数字码值没有绝对颜色颜色含义必须依赖色彩空间标签脱离色彩空间谈颜色没有意义。色深方面8bit 面板做平滑渐变时经常出现肉眼可见的色阶断层10bit 面板的阶梯间距缩小到原来的四分之一就基本看不出来。模拟方法与 2.2 节完全一致在 R/G/B 三个通道上分别量化对比即可。做 UI 或 Web 前端显示的同学尤其要注意浏览器默认按 sRGB 处理 8bit 图像暗色背景上的渐变很容易出现色带解决方案包括在生成渐变时加入抖动噪声。另一个容易被忽略的点显示器的8bit FRC帧率控制抖动本质上是时间维度的抖动和空间抖动的原理一脉相承都是为了用有限灰阶模拟更平滑的过渡。5. 仿真调参时我踩过、也见人反复踩的几个坑5.1 显示工具不等于真实显示链路做显示链路仿真时工具本身的显示逻辑会埋雷。OpenCV 的 imshow 默认按 BGR 顺序解释数据如果你直接拿 RGB 图去显示红蓝会互换matplotlib 的 imshow 对 float 类型数据会做 min-max 归一化图像明明整体偏暗显示出来却亮得发白。我的建议是仿真里统一约定数据类型和通道顺序float 域一律归一化到 [0,1]显示前显式 clip不要依赖工具的默认行为。5.2 忘了建模 PSF混叠被严重低估这是我在 1.1 节就提过的坑值得再敲一次黑板。直接用高分辨率图做降采样来模拟传感器输出等于假设光学系统是完美的这在真实系统里不存在。我在调一套成像仿真时曾把镜头模糊从流程里临时去掉以提速结果算法指标涨了一截接入真实镜头后立刻打回原形。仿真必须包含光学模糊环节哪怕先用高斯模糊近似也比不做强。提示高斯模糊的 sigma 怎么定一个可用的起点是取像素间距的 0.8~1.2 倍然后再根据成像系统的 MTF 实测数据去校。别在这一步过度追求精确先让混叠行为合理再逐步加细节。5.3 uint8 中间量直接运算溢出后满屏诡异很多初学者喜欢把图像保持在 uint8 类型里一路算到底。相减得到负值会被截成 0相加超过 255 会被截成 255中间结果一错后面全是错的。我见过最典型的现象是两帧做差想提取运动目标结果全屏是黑色里带一圈圈异常亮斑——不是算法问题是溢出。正确处理是在读入后立刻转 float32 或 int16 计算输出前再 clip 回 uint8。5.4 伽马变换前忘归一化图像直接废掉做伽马编码仿真最常见的错误是在 0~255 范围直接对像素值做 1/2.2 次幂。大于 1 的数越开根号越大图变得整体发白不小心输入了负值还会产生 NaN后续所有统计直接崩掉。正确流程是uint8 转 float32 → 除以 255 归一化到 [0,1] → 做伽马变换 → 乘 255 并 clip → 转回 uint8。顺序不能乱取值范围不能省。5.5 给获取与显示仿真留一条可追溯的链路我的习惯是每次完成获取或显示链路的仿真都会保存一张链路图式的对比图原图、理想采样结果、含 PSF 采样结果、量化后、显示解码后几排图排在一起。这样一是方便检查哪一步引入的损伤最大二是给后续算法调优留下对照基线。有一次我调去马赛克算法PSNR 涨了 0.3dB 但主观效果变差了靠的就是这张链路图定位到问题是白平衡参数偏移而非去马赛克本身。做这套流程确实比直接 imread 多花几分钟但当你需要向别人解释为什么结果长这样的时候它会帮你省下几小时。最后再分享一个小经验如果你做的项目最终要上真机建议在仿真环境里把获取端和显示端的边界条件都设成和硬件一致的极值——比如真实传感器只给 10bit raw、真实屏幕只支持 8bit 输出。过早地在仿真里用理想显示评估算法很多在高比特下看起来很美的结果一到真机就露馅。把这两个环节当成算法的一部分去建模而不是当成可有可无的开关你的仿真离工程落地就近了一大半。