2. 反差对焦(CDAF)原理:图像对比度与爬山算法

反差对焦,圈里人常叫它 CDAF(Contrast Detection Auto Focus)。说白了,它不靠什么特殊传感器,就靠图像本身来判断焦清不清晰。你想想看,一张照片拍糊了,边缘是模糊的,像素之间的差异很小;拍清楚了,边缘锐利,相邻像素的亮度跳变就很大。CDAF 就是利用这个“跳变”的大小来找焦点。

我刚开始做相机算法时,觉得这玩意儿挺直观的。但真正上手调参才发现,坑不少。咱们今天就把它的核心——图像对比度、爬山算法、还有几个常用的评价函数——彻底讲透。

2.1 什么是图像对比度?

图像对比度,不是指整张图的明暗反差,而是指局部像素之间的灰度变化率。变化越剧烈,对比度越高,图像越清晰。

举个简单的例子:

  • 一张纯色墙的照片,所有像素值都是 128,对比度 = 0(完全模糊)。
  • 一张黑白条纹的照片,像素值在 0 和 255 之间来回跳,对比度很高(清晰)。

在自动对焦里,我们只关心对焦窗口(ROI)内的对比度。窗口外的背景再乱,也不参与计算。

核心公式(直觉版):
对比度 = Σ (相邻像素差的值) 或 Σ (像素梯度的平方)
值越大 → 图像越清晰 → 对焦越准。

2.2 爬山算法:如何找到峰值?

有了对比度这个“评分标准”,接下来就是怎么找到评分最高的镜头位置。这就是爬山算法(Hill Climbing)干的事。

它的逻辑特别简单,像个瞎子爬山:

  1. 迈一步:镜头朝一个方向移动一小步。
  2. 算分:计算当前帧的对比度值。
  3. 比较:如果分数比上一帧高,说明方向对了,继续走。
  4. 回头:如果分数比上一帧低,说明走过头了,掉头往回走,步长减半。
  5. 收敛:重复直到步长小于某个阈值,或者分数变化极小,就认为找到了峰值。

听起来简单吧?但我曾经在一个项目中踩过坑:如果场景里有高频纹理(比如树叶),对比度曲线会有很多小毛刺。爬山算法容易卡在局部峰值上,而不是真正的焦点。后来我加了粗搜+细搜两步策略,先大步跨过整个行程,找到最高峰的大致区域,再小步精细搜索,才解决了问题。

避坑指南:
我曾经在低光照环境下,因为噪声导致对比度曲线抖动剧烈,爬山算法反复震荡无法收敛。后来我引入了多帧平均和死区(Dead Zone)判断,才稳定下来。记住:噪声是 CDAF 的天敌。

2.3 评价函数详解

爬山算法需要一个“评分函数”来计算对比度。不同的函数,对纹理的敏感度、计算量、抗噪能力都不一样。下面是我最常用的三种:

2.3.1 Tenengrad 函数

Tenengrad 基于 Sobel 算子。它计算图像在水平和垂直方向的梯度,然后求平方和。说白了,就是检测边缘的强度。

// Tenengrad 评价函数(简化版)
float Tenengrad(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;
}

特点:

  • 对边缘敏感,评价曲线陡峭,精度高。
  • 计算量大(每个像素要做 6 次乘加)。
  • 对噪声敏感,低光下表现一般。

2.3.2 Laplacian 函数

Laplacian 是二阶微分算子。它不关心边缘的方向,只关心像素值变化的剧烈程度。我个人的习惯是,在纹理丰富的场景下优先用 Laplacian,因为它对焦速度更快。

// Laplacian 评价函数(简化版)
float Laplacian(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;
}

特点:

  • 计算量比 Tenengrad 小(只有 5 次加减)。
  • 对孤立噪声点非常敏感(二阶微分放大噪声)。
  • 在平滑区域容易失效(梯度为 0)。

2.3.3 Brenner 函数

Brenner 是最简单的评价函数之一。它只计算水平方向相隔两个像素的灰度差的平方。我建议在资源受限的嵌入式平台上优先考虑它。

// Brenner 评价函数(简化版)
float Brenner(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] - img[y*width + x + 2];
            sum += (float)(diff * diff);
        }
    }
    return sum;
}

特点:

  • 计算量极小(一次减法、一次乘法)。
  • 只考虑水平方向,对垂直纹理不敏感。
  • 抗噪能力中等,适合快速粗搜。

2.4 三种评价函数对比

函数 计算量 精度 抗噪性 适用场景
Tenengrad 高 高 低 高光照、纹理丰富场景
Laplacian 中 中高 极低 纹理丰富、噪声可控场景
Brenner 低 中 中 嵌入式平台、粗搜阶段
我的经验:
在实际项目中,我很少只用一种函数。通常的做法是:粗搜用 Brenner(快),细搜用 Tenengrad(准)。如果场景噪声大,我会在 Laplacian 之前加一个高斯滤波,虽然多花点时间,但能避免误判。

2.5 核心流程:CDAF 对焦逻辑

下面这张 SVG 图展示了 CDAF 的完整工作流程。从图像采集到最终锁定焦点,每一步都离不开我们刚才讲的内容。

CDAF 对焦流程 CDAF 对焦核心流程 图像采集 计算评价函数(Tenengrad/Laplacian/Brenner) 爬山算法:比较当前帧与上一帧 分数上升? 继续同向移动 分数下降? 掉头 + 步长减半 步长 < 阈值? 锁定焦点 说明: 爬山算法通过“比较-移动-再比较”的循环,逐步逼近对比度峰值。步长减半策略保证了收敛精度,但需要设置合理的终止条件(步长阈值或分数变化量)。

2.6 实际项目中的取舍

最后聊点实际的。你在做 CDAF 算法时,一定会面临三个选择:

  • 精度 vs 速度:Tenengrad 精度高但慢,Brenner 快但精度一般。我的做法是动态切换——运动场景用 Brenner 快速跟焦,静止场景用 Tenengrad 精对焦。
  • 窗口大小:窗口越大,计算越慢,但抗干扰能力越强。我一般取图像中心 1/4 区域,既保证速度,又覆盖主体。
  • 步长策略:固定步长容易过冲或震荡。我习惯用指数衰减步长,前期大步快速逼近,后期小步精细调整。
再提醒一次:
我曾经在一个项目中,因为评价函数没有做归一化处理,导致不同光照下的对比度值差异巨大,爬山算法的阈值完全失效。后来我加了归一化(除以窗口内的平均亮度),才让算法在各种光照下都能稳定工作。这个坑,你大概率也会遇到。