2. 对焦评价函数(上):基于梯度的清晰度评价函数

好,咱们直接进入正题。对焦评价函数,说白了就是给镜头算命的——判断当前画面到底是清楚还是模糊。我做了这么多年相机系统,见过太多人一上来就调PID参数,结果评价函数本身就有问题,那后面全是白费功夫。

这一章我们先啃最经典的一类:基于梯度的清晰度评价函数。为什么先讲它们?因为梯度直接对应图像边缘的锐利程度,而边缘锐利程度恰恰就是人眼判断是否合焦的核心依据。

2.1 梯度与清晰度的关系

先想一个问题:一张合焦的照片和一张虚焦的照片,最直观的区别在哪?

答案是边缘。合焦时,物体边缘的亮度变化是陡峭的;虚焦时,边缘被模糊了,亮度变化变得平缓。梯度就是用来量化这个「变化陡峭程度」的数学工具。

核心思想:清晰图像 → 边缘锐利 → 梯度值大;模糊图像 → 边缘平滑 → 梯度值小。

我刚开始做自动对焦时,犯过一个低级错误:直接用原始像素值做评价。结果发现,画面亮度一变,评价曲线就乱跳。后来才明白,梯度算子本质上是一个高通滤波器,它滤掉了低频的亮度分量,只保留边缘信息。这样评价函数就对光照变化不那么敏感了。

下面这张图展示了三种经典梯度算子的计算逻辑:

三种梯度算子计算逻辑对比 Brenner 算子 D(x,y) = |I(x+2,y) - I(x,y)|² 相隔2像素的差分平方 Tenengrad 算子 G(x,y) = √(Gx² + Gy²) Sobel 梯度幅值 Laplacian 算子 ∇²I = ∂²I/∂x² + ∂²I/∂y² 二阶导数算子 特性 • 计算量最小 • 对噪声敏感 • 适合硬件实现 特性 • 抗噪性较好 • 方向敏感 • 最常用方案 特性 • 各向同性 • 对噪声最敏感 • 需先做平滑 共同目标:量化图像边缘锐利程度,为对焦搜索提供评价依据

2.2 Brenner 算子:最朴素的梯度评价

Brenner 算子是梯度评价里最「暴力」的一种。它只做一件事:计算相隔两个像素的亮度差,然后平方求和。

数学表达式很简单:

F = Σ_x Σ_y [I(x+2, y) - I(x, y)]²

为什么隔两个像素而不是一个?我个人的理解是:隔一个像素对噪声太敏感,隔两个像素相当于做了一个简单的平滑,反而更鲁棒。当然,如果你的传感器噪声很小,用步长1也行。

实战经验:Brenner 算子的计算量极低,非常适合嵌入式平台。我在一个低功耗摄像头项目里就用它,配合简单的爬山搜索,帧率能做到30fps不卡顿。

代码实现:

float brenner_eval(uint8_t* img, int width, int height) {
    float sum = 0.0f;
    for (int y = 0; y < height; y++) {
        for (int x = 0; x < width - 2; x++) {
            int diff = img[y * width + x + 2] - img[y * width + x];
            sum += (float)(diff * diff);
        }
    }
    return sum / (width * height);  // 归一化,方便不同分辨率对比
}

注意我做了归一化处理。如果不归一化,分辨率越大,F值越大,你就没法在不同分辨率之间做公平比较了。

2.3 Tenengrad 算子:引入 Sobel 的梯度幅值

Tenengrad 比 Brenner 讲究一些。它用 Sobel 算子计算水平和垂直两个方向的梯度,然后合成梯度幅值。

Sobel 算子有两个3x3的卷积核:

Gx = [[-1, 0, 1],
      [-2, 0, 2],
      [-1, 0, 1]]

Gy = [[-1, -2, -1],
      [ 0,  0,  0],
      [ 1,  2,  1]]

每个像素的梯度幅值:

G(x,y) = √(Gx² + Gy²)

整幅图的 Tenengrad 评价值就是所有像素梯度幅值的平方和:

F = Σ_x Σ_y [G(x,y)]²

注意:开平方运算在嵌入式平台上很慢。实际工程中,我经常直接用 Gx² + Gy² 代替 √(Gx² + Gy²),评价曲线的形状几乎不变,但速度能快30%以上。

代码实现:

float tenengrad_eval(uint8_t* img, int width, int height) {
    float sum = 0.0f;
    for (int y = 1; y < height - 1; y++) {
        for (int x = 1; x < width - 1; x++) {
            // Sobel Gx
            int gx = -img[(y-1)*width + (x-1)] + img[(y-1)*width + (x+1)]
                     -2*img[y*width + (x-1)] + 2*img[y*width + (x+1)]
                     -img[(y+1)*width + (x-1)] + img[(y+1)*width + (x+1)];
            // Sobel Gy
            int gy = -img[(y-1)*width + (x-1)] -2*img[(y-1)*width + x] - img[(y-1)*width + (x+1)]
                     +img[(y+1)*width + (x-1)] +2*img[(y+1)*width + x] + img[(y+1)*width + (x+1)];
            sum += (float)(gx*gx + gy*gy);  // 省略开平方
        }
    }
    return sum / (width * height);
}

Tenengrad 是我个人用得最多的算子。它在抗噪性和灵敏度之间取得了不错的平衡。不过要注意,它对纹理方向敏感——如果场景里全是水平条纹,垂直方向的梯度贡献就很小。

2.4 Laplacian 算子:二阶导数的视角

Laplacian 走的是另一条路——它不关心梯度本身,而是关心梯度的变化率。说白了,它用二阶导数来检测边缘。

连续形式的 Laplacian:

∇²I = ∂²I/∂x² + ∂²I/∂y²

离散形式的常用卷积核(4邻域):

L = [[ 0, -1,  0],
     [-1,  4, -1],
     [ 0, -1,  0]]

或者8邻域版本:

L8 = [[-1, -1, -1],
      [-1,  8, -1],
      [-1, -1, -1]]

评价值就是所有像素 Laplacian 响应值的平方和:

F = Σ_x Σ_y [∇²I(x,y)]²

关键区别:Brenner 和 Tenengrad 是一阶梯度,响应在边缘处达到峰值;Laplacian 是二阶梯度,响应在边缘两侧符号相反,过零点才是边缘位置。所以 Laplacian 对孤立噪声点特别敏感——一个噪点就能产生巨大的二阶响应。

代码实现:

float laplacian_eval(uint8_t* img, int width, int height) {
    float sum = 0.0f;
    for (int y = 1; y < height - 1; y++) {
        for (int x = 1; x < width - 1; x++) {
            int lap = 4 * img[y*width + x]
                      - img[(y-1)*width + x]
                      - img[(y+1)*width + x]
                      - img[y*width + (x-1)]
                      - img[y*width + (x+1)];
            sum += (float)(lap * lap);
        }
    }
    return sum / (width * height);
}

我曾经在一个项目里直接用 Laplacian 做评价,结果发现画面稍微有点噪点,评价曲线就抖得厉害。后来我加了一个高斯预滤波,曲线才平滑下来。所以我的建议是:用 Laplacian 之前,一定先做平滑处理。

2.5 三种算子的对比与选型建议

我把三种算子的关键特性整理成了一张表,方便你快速选型:

特性 Brenner Tenengrad Laplacian
数学阶数 一阶 一阶 二阶
计算量 极低 中等 低
抗噪性 中等 较好 差
方向敏感性 水平方向 各向同性(近似) 各向同性
适用场景 低功耗、实时性要求高 通用场景,推荐首选 需配合平滑滤波使用
硬件实现难度 极简 简单 简单

我的选型建议:

  • 如果你在做低功耗 IoT 摄像头,选 Brenner,省电又够用。
  • 如果你在做手机或相机 AF,选 Tenengrad,综合表现最稳。
  • 如果你在做显微镜或工业检测,场景可控且噪声低,Laplacian 的灵敏度更高。

2.6 一个容易被忽视的细节:ROI 区域选择

讲完算子本身,我想提一个工程上特别重要但教科书很少讲的问题——ROI(感兴趣区域)的选择。

你想想看,如果整幅图都参与评价,天空、墙壁这些平坦区域贡献的梯度值几乎为零,它们只会稀释有效信息。更糟糕的是,如果画面边缘有镜头渐晕(暗角),这些区域的梯度值还会引入偏差。

我一般会这么做:

  1. 只取画面中央 60%-80% 的区域做评价。
  2. 如果场景中有已知的纹理丰富区域(比如人脸检测框),只在该区域内计算。
  3. 对 ROI 内的像素做加权——中心权重高,边缘权重低。

代码里加一个 ROI 参数并不复杂:

float tenengrad_eval_roi(uint8_t* img, int width, int height,
                         int roi_x, int roi_y, int roi_w, int roi_h) {
    float sum = 0.0f;
    for (int y = roi_y + 1; y < roi_y + roi_h - 1; y++) {
        for (int x = roi_x + 1; x < roi_x + roi_w - 1; x++) {
            // 同样的 Sobel 计算,但只遍历 ROI 区域
            // ...
        }
    }
    return sum / (roi_w * roi_h);
}

避坑指南:我曾经在一个项目中,ROI 选得太小(只占画面的10%),结果镜头稍微抖动一下,目标物体跑出 ROI,评价函数直接崩了。ROI 的大小要留有余量,至少覆盖你期望的对焦区域加上一定的运动容差。

2.7 小结

这一章我们聊了三种基于梯度的清晰度评价函数。它们虽然简单,但却是自动对焦系统的基石。记住一个原则:评价函数的质量决定了对焦精度的上限,后面的搜索算法再优秀,也弥补不了评价函数的缺陷。