3、镜头阴影校正(LSC):从暗角到均匀
镜头阴影校正,说白了就是解决照片四周发暗的问题。你拿起手机拍一张白墙,如果发现中间亮、四周暗,那就是典型的镜头阴影现象。我刚开始做ISP时,觉得这问题不大,直到有一次给客户演示,样张的暗角被对方一眼看出来……嗯,从那以后我再也不敢小看LSC了。
3.1 镜头阴影现象:为什么会暗角?
镜头阴影也叫“暗角”或“亮度衰减”。它主要由两个原因造成:
- 光学原因:镜头本身是个凸透镜,光线从中心到边缘的路径不同。边缘光线入射角大,到达传感器的光量自然少。这就像手电筒直射和斜射的区别——斜射时能量分散了。
- 机械原因:镜头筒本身会遮挡一部分边缘光线,尤其是广角镜头。我做过一个项目,用了大光圈广角模组,暗角直接掉了30%以上,不校正根本没法看。
阴影的典型表现是:中心亮度正常,从中心到四周亮度逐渐下降。下降曲线通常可以用余弦四次方定律近似描述,但实际模组因为镜片设计和组装公差,往往不是完美的对称形状。
关键指标:镜头阴影通常用“中心与边缘亮度比”来衡量。比如中心亮度100,边缘只有70,那阴影就是30%。一般要求校正后边缘亮度达到中心的95%以上。
3.2 LSC校正原理:乘回去就对了
校正原理其实很简单——乘一个增益系数。既然边缘光线弱,那就在边缘像素上乘一个大于1的系数,把亮度补回来。
数学表达就是:
I_corrected(x, y) = I_raw(x, y) × Gain(x, y)
其中 Gain(x, y) 就是每个像素对应的增益值。中心区域增益接近1,边缘区域增益大于1。
但这里有个坑:增益不能无限大。我曾经遇到一个极端案例,边缘增益设到2.5倍,结果校正后边缘噪声被放大了,反而比不校正还难看。所以实际工程中,增益上限通常限制在2.0~2.5倍,超过这个范围就属于镜头设计问题了。
注意:LSC校正是在RAW域做的,必须在去马赛克(Demosaic)之前完成。因为增益操作是线性的,RAW域数据最干净。如果在RGB或YUV域做,会引入颜色失真。
3.3 增益网格生成:从标定到网格
增益怎么来?不是算出来的,是标定出来的。
标定流程大致如下:
- 用均匀光源照射一张白板(比如DNP灯箱),拍一张RAW图。
- 理论上这张图应该是均匀的,但因为镜头阴影,实际是不均匀的。
- 取中心区域的平均亮度作为参考值 L_ref。
- 每个像素的理想增益 = L_ref / L_pixel。
但问题来了——传感器有几百万甚至上千万像素,每个像素存一个增益值,那存储量太大了。所以实际做法是:只存一个稀疏的网格,比如 16×16 或 32×32 的网格点,每个网格点对应一个增益值。使用时再通过插值算出每个像素的增益。
| 网格尺寸 | 存储量(单通道) | 精度 | 适用场景 |
|---|---|---|---|
| 8×8 | 64个值 | 较低 | 低端摄像头、低分辨率 |
| 16×16 | 256个值 | 中等 | 主流手机、安防 |
| 32×32 | 1024个值 | 较高 | 高端相机、医疗影像 |
| 64×64 | 4096个值 | 极高 | 专业摄影、卫星遥感 |
我个人习惯用 16×16 网格,性价比最高。32×32 虽然精度更好,但标定和存储成本都翻倍,对大部分消费级产品来说没必要。
3.4 双线性插值实现:从网格到像素
有了网格,怎么算每个像素的增益?最常用的方法是双线性插值。
原理很简单:对于任意像素 (x, y),找到它周围的四个网格点,按距离加权平均。
假设四个网格点的增益分别是 G00、G01、G10、G11,像素在网格内的相对位置是 (dx, dy),那么:
// 先做水平方向插值
G0 = G00 × (1 - dx) + G01 × dx
G1 = G10 × (1 - dx) + G11 × dx
// 再做垂直方向插值
G = G0 × (1 - dy) + G1 × dy
这就是双线性插值的核心。实际代码实现时,要注意边界处理——图像边缘的像素可能只有两个或一个相邻网格点。
工程技巧:我建议把网格增益预先转成定点数(比如Q8.8格式),这样插值计算可以用整数运算代替浮点,速度能快3~5倍。在嵌入式平台上,这个优化非常关键。
下面是一个简单的C语言实现片段:
// 假设网格尺寸为 grid_w × grid_h,图像尺寸为 img_w × img_h
// gain_grid 是二维数组,存储网格点的增益值
float get_gain(int x, int y) {
// 计算网格坐标
float grid_x = (float)x / img_w * (grid_w - 1);
float grid_y = (float)y / img_h * (grid_h - 1);
// 找到四个相邻网格点
int gx0 = (int)grid_x;
int gy0 = (int)grid_y;
int gx1 = min(gx0 + 1, grid_w - 1);
int gy1 = min(gy0 + 1, grid_h - 1);
// 计算相对位置
float dx = grid_x - gx0;
float dy = grid_y - gy0;
// 双线性插值
float g00 = gain_grid[gy0][gx0];
float g01 = gain_grid[gy0][gx1];
float g10 = gain_grid[gy1][gx0];
float g11 = gain_grid[gy1][gx1];
float g0 = g00 * (1 - dx) + g01 * dx;
float g1 = g10 * (1 - dx) + g11 * dx;
return g0 * (1 - dy) + g1 * dy;
}
实际项目中,我不会对每个像素都调用这个函数——那样太慢了。我会逐行预计算:先算好当前行所有像素的增益,存到一个行缓冲区里,然后整行一起处理。这样内存访问更连续,cache命中率更高。
3.5 整体流程与避坑
把整个LSC流程串起来,大概是这样的:
最后说几个我踩过的坑:
- 网格对齐问题:增益网格的坐标原点必须和图像坐标原点严格对齐。我曾经因为一个像素的偏移,导致校正后出现条纹状伪影,查了两天才找到原因。
- 温度漂移:镜头阴影会随温度变化。我在一个户外项目中,白天和晚上的阴影曲线差了5%以上。后来加了温度补偿,才解决问题。
- 多通道独立校正:Bayer格式的R、G、B通道阴影曲线不同,必须分别标定、分别校正。如果共用一套增益,会出现颜色偏移——画面四周偏绿或偏红。
避坑指南:我曾经在一个项目中偷懒,把R和B通道的增益直接复用G通道的。结果样张四周出现了明显的紫边,被客户当场退货。从那以后,我再也不敢省这一步了。