4、镜头阴影校正(LSC):从现象到实战
镜头阴影校正,英文叫 Lens Shading Correction,简称 LSC。这玩意儿在 ISP 流水线里,属于那种「不做不行,做了又觉得没啥技术含量」的模块。但说实话,我早年吃过它的亏——有一次调试一款手机摄像头,中心亮度明明正常,四周却暗得像黄昏,客户直接打回。后来才发现,就是 LSC 参数没调好。
好,咱们今天就把这个「阴影」彻底讲透。
4.1 镜头阴影现象:为什么四周会变暗?
你拍过纯色白墙吗?如果拍出来中心亮、四周暗,那就是镜头阴影。说白了,这是物理限制,不是算法能完全消除的。
成因主要有两个:
- 光强衰减(自然规律):镜头是个圆,传感器是个矩形。边缘像素接收到的光线,入射角更大,光程更长,能量自然衰减。这符合余弦四次方定律——嗯,理论上是这样,但实际镜头还会更复杂。
- 机械遮挡(设计缺陷):镜头模组内部的镜筒、遮光片,会挡住一部分斜射光线。尤其是小光圈下,这种「渐晕」更明显。
关键认知:镜头阴影是乘性噪声,不是加性噪声。这意味着你不能简单减一个固定值,而是要对每个像素乘一个增益系数。
我记得有一次,团队里新来的同事直接用减法去校正,结果四周是亮了,但噪声也被放大了,画面反而更难看。嗯,方向错了,努力白费。
4.2 LSC 原理与增益矩阵:一张「补光地图」
LSC 的核心思想很简单:给每个像素算一个增益值,让暗的地方亮起来,亮的地方保持不变。
这个增益值组成的二维数组,就叫 增益矩阵(Gain Matrix)。通常我们只计算网格点上的增益,中间像素靠插值得到。
你想想看,如果传感器是 4000×3000 的分辨率,每个像素都存一个增益,那内存就炸了。所以实际做法是:
- 把图像分成 16×16 或 32×32 的网格
- 只计算每个网格顶点的增益
- 运行时用双线性插值算出每个像素的增益
我的习惯:网格密度不要一刀切。中心区域光照变化平缓,网格可以稀疏些;边缘区域阴影变化剧烈,网格要加密。我曾经在 8×8 网格上翻过车——边缘出现明显的块状伪影,后来改成 16×16 才解决。
4.3 径向模型与多项式拟合:用数学描述阴影
增益矩阵怎么算?最经典的方法是 径向模型。
假设镜头阴影是旋转对称的——也就是说,离中心越远,衰减越严重。那么增益只跟像素到图像中心的距离 r 有关。
数学表达就是:
G(r) = 1 + a1 * r² + a2 * r⁴ + a3 * r⁶ + ...
为什么只用偶次项?因为阴影分布是中心对称的,奇次项会引入不对称。嗯,这里要注意:实际镜头可能不完全对称,但径向模型作为起点已经够用。
拟合过程分三步:
- 采集数据:拍一张均匀亮度的灰卡或白墙
- 提取曲线:沿径向取多个点的亮度值,归一化到中心亮度
- 多项式拟合:用最小二乘法算出系数 a1, a2, a3...
我曾经踩过的坑:用高阶多项式(比如 8 阶)拟合时,边缘会出现剧烈震荡(Runge 现象)。后来我改用 4 阶或 6 阶,配合加权最小二乘,效果稳定多了。别贪高阶,够用就行。
4.4 实战:基于网格的 LSC 校正
理论讲完了,咱们直接上代码。下面是一个简化版的网格 LSC 校正实现:
// 假设输入图像为 8-bit 灰度图,尺寸 width x height
// grid_w, grid_h 为网格数,例如 16x16
// gain_grid 为预先计算好的增益网格,尺寸 (grid_w+1) x (grid_h+1)
void lsc_correct(uint8_t* img, int width, int height,
int grid_w, int grid_h, float* gain_grid) {
float step_x = (float)(width - 1) / grid_w;
float step_y = (float)(height - 1) / grid_h;
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
// 计算当前像素在网格中的位置
float gx = x / step_x;
float gy = y / step_y;
int ix = (int)gx;
int iy = (int)gy;
float fx = gx - ix;
float fy = gy - iy;
// 边界保护
if (ix >= grid_w) ix = grid_w - 1;
if (iy >= grid_h) iy = grid_h - 1;
// 双线性插值获取增益
float g00 = gain_grid[iy * (grid_w+1) + ix];
float g10 = gain_grid[iy * (grid_w+1) + ix + 1];
float g01 = gain_grid[(iy+1) * (grid_w+1) + ix];
float g11 = gain_grid[(iy+1) * (grid_w+1) + ix + 1];
float gain = (1-fx)*(1-fy)*g00 + fx*(1-fy)*g10 +
(1-fx)*fy*g01 + fx*fy*g11;
// 应用增益,注意饱和
int val = (int)(img[y * width + x] * gain + 0.5f);
if (val > 255) val = 255;
img[y * width + x] = (uint8_t)val;
}
}
}
这段代码的核心就是 双线性插值。你想想看,如果直接用最近邻,边缘会出现锯齿;如果用三次插值,计算量又太大。双线性插值是性价比最高的选择。
性能优化建议:
- 把增益网格转成定点数(Q8.8 格式),避免浮点运算
- 用查表法代替每次的插值计算
- 在 ARM Neon 或 DSP 上做向量化
我个人习惯在调试阶段先用浮点版本验证效果,确认无误后再转定点。这样能避免「定点溢出导致画面异常」这种低级错误。
4.5 知识体系总览
下面这张图总结了镜头阴影校正的完整流程,从现象到实现:
从这张图可以看得很清楚:LSC 不是孤立的一步,它从物理现象出发,经过数学建模,最终落到工程实现。每一步都有坑,但也都有解法。
好了,这一章的内容就到这里。记住:镜头阴影校正,说白了就是给图像边缘「补光」。但怎么补得自然、补得高效,才是真功夫。