2. 反差式对焦(CDAF)原理:基于图像对比度搜索峰值,爬山算法详解

反差式对焦,圈内人常叫它 CDAF(Contrast Detection Auto Focus)。说白了,它不关心你镜头里有没有相位检测点,也不管场景深度信息。它只做一件事——找图像最“锐”的那个位置。

怎么判断锐不锐?看对比度。图像越清晰,相邻像素之间的亮度差异就越大,对比度值就越高。反过来,画面一模糊,对比度就往下掉。CDAF 的核心逻辑就是:驱动镜头移动,同时实时计算图像对比度,直到找到对比度最大的那个点。

我早年做安防一体机的时候,第一次调 CDAF 算法,发现画面在某个位置来回震荡,死活定不下来。后来才明白,那是爬山算法的步长没设好。嗯,咱们先把原理讲透。

2.1 对比度评价函数:怎么量化“清晰度”?

要让机器判断画面清不清晰,得先有个数学指标。常用的对比度评价函数有好几种,我挑三个最典型的讲。

评价函数 计算方式 特点 我见过的坑
Tenengrad 函数 基于 Sobel 算子,提取图像梯度 抗噪性好,单峰性强 计算量大,嵌入式平台要优化
Laplacian 函数 二阶微分,对边缘敏感 响应快,峰值尖锐 对噪声极其敏感,容易误判
方差函数 计算像素灰度值的方差 实现简单,计算量小 平坦区域容易失效

我个人习惯用 Tenengrad 函数做主力。为什么?因为它在大多数场景下都能给出一个干净的单峰曲线。你想想看,如果评价函数本身就有多个峰值,爬山算法很容易爬到半山腰就以为自己到顶了。

关键认知:评价函数的选择直接决定了 CDAF 的成败。选错了函数,后面算法再精妙也白搭。

2.2 爬山算法:一步一步往“山顶”走

爬山算法(Hill Climbing)是 CDAF 最经典的搜索策略。它的思路特别直白:

  1. 先迈一步——驱动镜头向某个方向移动一小步。
  2. 算一下对比度——如果对比度变大了,说明方向对了,继续往这个方向走。
  3. 如果对比度变小了——说明走反了,掉头往回走。
  4. 重复以上步骤——直到对比度不再增加,甚至开始下降,就认为找到了峰值。

听起来很简单对吧?但实际工程里,问题全出在“一小步”上。

我曾经踩过的坑:步长设太大,镜头直接越过峰值,在峰值两侧来回振荡,永远定不下来。步长设太小,对焦过程慢得像蜗牛爬,用户早就没耐心了。

所以工业界通常用变步长策略:刚开始大步快跑,快速接近峰值区域;一旦检测到对比度变化趋势放缓,就切换成小步精细搜索。这就像你爬山,山脚可以大步跨,快到山顶就得一步一步挪。

2.3 爬山算法的核心流程

下面这张图是我自己画的 CDAF 爬山算法流程图,你看一眼就能明白整个逻辑。

CDAF 爬山算法流程图 CDAF 爬山算法核心流程 开始对焦 初始化镜头位置 & 步长 计算当前帧对比度 C0 沿当前方向移动一步 计算新对比度 C1 C1 > C0 ? (对比度是否上升?) 是 保持方向继续 否 掉头,减小步长 回到计算当前对比度 说明:当对比度不再上升时,说明已越过峰值,需要掉头并缩小步长进行精细搜索

这个流程里有一个关键判断:什么时候算“到顶了”?工程上常用的判据是:连续 N 次移动后,对比度变化量小于某个阈值。N 和阈值都需要根据具体镜头和场景来调。我一般先设 N=3,阈值取当前对比度值的 1%,然后根据实际效果微调。

2.4 爬山算法的三大痛点

做 CDAF 这么多年,我总结出三个绕不开的坑:

  • 局部峰值陷阱——评价函数如果有多个波峰,算法可能停在次高峰,永远找不到真正的焦点。我遇到过最典型的情况是:镜头前面有根细铁丝,对比度在铁丝位置出现一个假峰,算法就傻乎乎地停在那了。
  • 平坦区域失效——对着白墙或者天空,整个画面的对比度几乎不变,评价函数像一条平线。这时候爬山算法就像在沙漠里找方向,完全迷失。
  • 速度与精度的博弈——步长大则速度快但精度差,步长小则精度高但速度慢。没有银弹,只能根据场景做权衡。

我的经验:对于局部峰值问题,可以在搜索前先做一次粗扫,记录对比度曲线的整体趋势,然后再用爬山算法做精细定位。这相当于先看地图再爬山,不容易走偏。

2.5 代码示例:一个简化的爬山算法实现

下面这个 Python 代码是我在原型验证阶段常用的框架。它不复杂,但把核心逻辑都体现出来了。

def hill_climbing_af(lens_driver, frame_grabber, eval_func, initial_step=50):
    """
    爬山算法实现 CDAF 对焦
    :param lens_driver: 镜头驱动接口
    :param frame_grabber: 图像采集接口
    :param eval_func: 对比度评价函数
    :param initial_step: 初始步长(单位:镜头步进数)
    """
    # 初始化
    step = initial_step
    direction = 1  # 1: 正向, -1: 反向
    prev_contrast = eval_func(frame_grabber.get_frame())
    
    # 记录连续下降次数,用于判断是否到顶
    decline_count = 0
    max_decline = 3
    
    while step > 1:  # 步长小于1时停止
        # 移动镜头
        lens_driver.move(direction * step)
        current_contrast = eval_func(frame_grabber.get_frame())
        
        # 对比度变化判断
        if current_contrast > prev_contrast:
            # 对比度上升,继续同方向
            prev_contrast = current_contrast
            decline_count = 0
        else:
            # 对比度下降,掉头并减小步长
            direction *= -1
            step = step // 2  # 步长减半
            decline_count += 1
            
            if decline_count >= max_decline:
                # 连续下降多次,认为已到峰值附近
                break
    
    # 返回最终镜头位置
    return lens_driver.get_position()

这段代码里,我用了 step // 2 来做步长衰减。实际项目中,步长衰减系数需要根据镜头行程和精度要求来调。有的镜头线性度好,可以用 step * 0.7;有的镜头有回程差,衰减系数就得设大一点。

2.6 工程落地建议

如果你正在做 CDAF 的工程实现,我给你三条建议:

  • 先做评价函数选型测试——拿你的目标场景拍几组图,分别用 Tenengrad、Laplacian、方差算一遍,看哪个函数的单峰性最好。这一步花不了多少时间,但能省掉后面大量的调试痛苦。
  • 步长策略要分层——粗搜阶段步长可以设到镜头全行程的 5%-10%,精搜阶段步长缩到 1% 以内。我习惯用三级步长:粗搜、中搜、精搜,每级步长差 3-5 倍。
  • 加一个超时保护——万一算法陷入死循环或者场景太极端,不能让它无限跑下去。我一般设 2 秒超时,超时后直接返回当前最佳位置。

一句话总结:CDAF 的核心就是“评价函数 + 爬山搜索”。评价函数选对了,爬山算法调好了,剩下的就是工程细节的打磨。