3、反差对焦原理:图像清晰度评价函数与爬山算法

反差对焦,说白了就是让相机自己判断“哪张照片最清楚”。它不像相位对焦那样靠物理测距,而是直接在图像上算——哪个画面的边缘最锐利、细节最丰富,哪个就是对焦成功的。

我刚开始接触这个模块时,总觉得它很简单:不就是算个梯度嘛。后来在项目里踩了坑才发现,评价函数选不对、爬山步长设不好,画面就会来回震荡,死活合不上焦。嗯,今天咱们就把这块彻底讲透。

3.1 图像清晰度评价函数

评价函数是反差对焦的“眼睛”。它要回答一个问题:当前这幅图像,到底有多清晰?

清晰度的本质是图像中高频信息的多少。一张模糊的照片,边缘是平滑过渡的;一张清晰的照片,边缘是陡峭跳变的。所以,所有评价函数都在做同一件事——提取图像的高频分量。

常用的评价函数有三种:Tenengrad、Laplacian 和 Brenner。我按个人习惯把它们分成两类:基于一阶导数的(Tenengrad、Brenner)和基于二阶导数的(Laplacian)。

3.1.1 Tenengrad 函数

Tenengrad 用的是 Sobel 算子。它分别计算图像在水平方向和垂直方向的梯度,然后取平方和或值之和。

公式长这样:

Gx = Sobel_x * Image
Gy = Sobel_y * Image
Tenengrad = Σ( Gx² + Gy² )

我在项目中遇到过一个问题:Tenengrad 对噪声比较敏感。如果 sensor 的读出噪声偏大,Sobel 算出来的梯度里会混进很多假边缘,导致评价曲线不平滑。后来我加了一个 3×3 的中值滤波做预处理,效果好了不少。

小技巧: Tenengrad 的窗口大小可以调。我个人习惯用 3×3 的 Sobel 核,速度快;如果场景纹理很细,可以试试 5×5,能捕获更宽范围的边缘信息。

3.1.2 Laplacian 函数

Laplacian 用的是二阶导数。它不区分方向,直接检测图像中的“突变点”。

标准 Laplacian 核:

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

评价值为:

Laplacian_value = Σ |Laplacian(Image)|

你想想看,二阶导数对边缘的响应比一阶导数更尖锐。所以 Laplacian 的峰值更陡,对焦精度理论上更高。

但代价是什么?它更容易受噪声干扰。我曾经在一个低照度项目里试过 Laplacian,画面稍微暗一点,评价曲线就全是毛刺,爬山算法根本没法收敛。后来我换回了 Tenengrad,虽然峰值没那么尖,但稳定多了。

避坑指南: 如果 sensor 的 SNR 低于 40dB,不建议直接用 Laplacian。可以先做高斯模糊降噪,但要注意——模糊半径太大会把真实边缘也抹掉,反而降低对焦精度。

3.1.3 Brenner 函数

Brenner 是最简单的评价函数之一。它只计算水平方向相隔两个像素的灰度差的平方。

Brenner_value = Σ (I(x+2, y) - I(x, y))²

为什么是相隔两个像素?因为自然图像中相邻像素的相关性很高,相隔两个像素能更好地捕获边缘信息,同时抑制一部分噪声。

Brenner 的优点是计算量极小,适合嵌入式平台。我在一个用 ARM Cortex-M4 的相机项目里就用它,每帧只需要几十微秒就能算出评价值。

但它的缺点也很明显:只考虑水平方向,对垂直纹理不敏感。如果场景里全是横条纹,Brenner 的评价曲线会非常平,爬山算法容易陷入局部极值。

3.1.4 三种函数对比

评价函数 原理 计算量 抗噪性 峰值锐度 适用场景
Tenengrad Sobel 一阶梯度 中 中 中 通用场景,推荐首选
Laplacian 二阶差分 低 弱 高 高 SNR、高精度需求
Brenner 水平像素差平方 极低 较强 低 嵌入式、低算力平台

3.2 爬山算法

有了评价函数,我们就能给每一帧图像打一个“清晰度分”。接下来要解决的问题是:怎么通过移动镜头,找到分数最高的那个位置?

爬山算法就是干这个的。它模拟人爬山的过程:往高处走,直到山顶。

3.2.1 算法流程

标准的爬山算法分三步:

  1. 确定初始方向:先往一个方向移动一步,计算评价值。如果变大了,说明方向对了;如果变小了,就反向。
  2. 持续爬升:沿着当前方向继续移动,每步都计算评价值。只要评价值在上升,就继续走。
  3. 检测峰值:当评价值开始下降时,说明已经越过了山顶。回退一步,或者用插值找到精确峰值位置。

伪代码:

def hill_climbing():
    pos = current_position
    step = initial_step_size
    direction = +1
    
    # 确定方向
    score0 = evaluate(pos)
    score1 = evaluate(pos + step)
    if score1 < score0:
        direction = -1
    
    # 爬升
    while True:
        pos += direction * step
        score_new = evaluate(pos)
        if score_new < score_prev:
            # 越过峰值,回退
            pos -= direction * step
            break
        score_prev = score_new
    
    return pos

3.2.2 步长策略

步长是爬山算法的灵魂。步长太大,容易跳过峰值;步长太小,对焦速度慢得像蜗牛。

我常用的策略是变步长:

  • 粗搜阶段:用大步长(比如镜头全行程的 1/10),快速找到峰值附近区域。
  • 精搜阶段:换小步长(比如 1/50),精确锁定峰值位置。

举个例子,一个镜头行程是 1000 步(步进电机单位),粗搜步长设 100 步,精搜步长设 20 步。这样粗搜最多走 10 步就能到峰值附近,精搜再走 5-10 步就能收敛。总步数控制在 20 步以内,对焦时间可以做到 100ms 以内。

关键参数: 粗搜步长建议设为景深对应的电机步数。如果景深是 50 步,粗搜步长就设 50 步左右,保证不会跳过整个合焦范围。

3.2.3 局部极值与全局峰值

爬山算法最大的坑是局部极值。如果场景里有多个清晰区域(比如前景和背景都有纹理),评价曲线会出现多个峰值。爬山算法只能爬到最近的那个山顶,不一定是全局最高峰。

怎么解决?我分享两个经验:

  • 多起点爬山:在镜头行程上均匀选 3-5 个起点,分别爬山,取评价值最高的那个结果。代价是计算量翻倍,但对焦可靠性大幅提升。
  • 结合场景先验:如果是人脸对焦,可以优先搜索画面中央区域对应的镜头位置。我在做手机摄像头时,就利用人脸检测框的位置来缩小搜索范围,既快又准。
注意: 不要试图用爬山算法处理运动场景。如果物体在移动,评价曲线会不断变化,爬山算法永远追不上。这种情况应该用连续对焦(CAF)或者相位对焦。

3.3 评价曲线与对焦窗口

评价函数算的是整幅图像吗?不是。通常我们只取画面中的对焦窗口(Focus Window)来计算。

为什么?因为整幅图算的话,背景的纹理可能会干扰对焦。比如拍人像时,背景的树叶比人脸更清晰,评价函数就会把镜头拉到背景上。

对焦窗口的选择原则:

  • 大小适中:窗口太大,容易引入干扰;窗口太小,可能没有足够纹理。我一般取画面面积的 10%-20%。
  • 位置灵活:可以放在画面中央,也可以跟随人脸/物体检测框移动。
  • 多窗口融合:有些方案用多个小窗口分别计算,然后加权平均。权重可以根据窗口内的纹理丰富度动态调整。

我曾经在一个项目里遇到过这样的问题:对焦窗口刚好落在纯色区域(比如白墙),评价函数输出几乎为 0,爬山算法直接“失明”。后来我加了一个纹理检测逻辑——如果窗口内的方差小于阈值,就自动扩大窗口或者切换到相邻区域。

3.4 本章知识结构图

下面这张图总结了反差对焦的核心逻辑:

反差对焦核心逻辑 图像帧输入 对焦窗口提取 Tenengrad Laplacian Brenner 清晰度评价值 爬山算法搜索峰值 合焦位置输出 核心思路:提取高频信息 → 计算清晰度分数 → 爬山搜索 → 找到峰值位置

3.5 工程实践要点

最后,我总结几个在实际调试中容易忽略的点:

  • 评价函数的归一化:不同亮度下,评价值的值会变化。建议做归一化处理,或者用相对变化量来判断峰值。
  • 死区处理:当评价值变化小于某个阈值时,认为已经合焦,停止搜索。避免镜头在峰值附近来回震荡。
  • 超时保护:爬山算法一定要设最大步数限制。我曾经遇到过场景纹理太少,算法一直找不到峰值,镜头走到物理限位还在跑,差点烧了电机。
  • 与相位对焦的配合:在混合对焦系统中,反差对焦通常作为精调阶段使用。相位对焦先快速拉到大致位置,反差对焦再做微调。这个配合逻辑,咱们后面章节会详细讲。
一句话总结: 反差对焦的本质是“算图找峰”。评价函数选对、爬山步长设好、窗口位置合理,这套系统就能又快又准。