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⁶ + ...

为什么只用偶次项?因为阴影分布是中心对称的,奇次项会引入不对称。嗯,这里要注意:实际镜头可能不完全对称,但径向模型作为起点已经够用。

拟合过程分三步:

  1. 采集数据:拍一张均匀亮度的灰卡或白墙
  2. 提取曲线:沿径向取多个点的亮度值,归一化到中心亮度
  3. 多项式拟合:用最小二乘法算出系数 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)知识体系 镜头阴影现象 光强衰减(余弦四次方) 机械遮挡(渐晕) 乘性噪声特性 LSC 原理:增益矩阵 径向模型(多项式拟合) 网格增益矩阵 双线性插值 实战:基于网格的 LSC 校正 性能优化:定点化 / 查表 / 向量化 网格密度需权衡 精度与性能

从这张图可以看得很清楚:LSC 不是孤立的一步,它从物理现象出发,经过数学建模,最终落到工程实现。每一步都有坑,但也都有解法。

好了,这一章的内容就到这里。记住:镜头阴影校正,说白了就是给图像边缘「补光」。但怎么补得自然、补得高效,才是真功夫。