3、对比度对焦(CDAF)详解:爬山算法实现、窗口选择策略、评价函数设计
对比度对焦,圈内人常叫它 CDAF(Contrast Detection Auto Focus)。说白了,就是通过判断画面清晰度来驱动马达找焦点。你想想看,一张照片拍得清不清楚,最直观的感受就是边缘锐不锐、细节有没有。CDAF 就是利用这个原理,让镜头在移动过程中不断计算图像的“清晰度分数”,分数最高的位置,就是合焦点。
我刚开始做手机相机时,觉得这玩意儿挺简单的——不就是找最大值嘛。后来踩的坑多了才发现,爬山算法怎么爬、窗口选多大、评价函数用哪个,每一步都有讲究。今天咱们就把这三块掰开揉碎了讲。
3.1 爬山算法:从“盲人摸象”到“精准登顶”
爬山算法的思路很直观。想象你蒙着眼睛站在一座山上,想找到山顶。你只能通过脚底的坡度来判断方向——哪边高就往哪边挪一步。这就是 CDAF 的核心逻辑。
具体到代码实现,我习惯把流程拆成三步:
- 粗搜阶段:大步走,快速找到峰值区域。步长通常设为焦深的 2-3 倍。
- 细搜阶段:在峰值附近小步回走,精确定位。步长缩到焦深的 1/2 或 1/3。
- 确认阶段:在峰值点前后各取一帧,确认分数确实下降,防止误判。
这里有个关键点——什么时候判定“到顶了”?我见过不少方案用“连续下降 N 次就停”,但实际场景中,噪声会导致分数抖动。我曾经在暗光环境下调试,明明已经过了峰值,分数却因为噪声又跳高了,结果镜头直接拉过头。后来我改用“下降后回退确认”的策略:检测到下降后,再往回走一步,如果分数回升,说明还在坡上;如果继续降,才确认是山顶。
核心经验:爬山算法的步长和方向判断,必须结合镜头马达的迟滞特性。我建议在粗搜阶段加入“过冲补偿”——故意多走半步再回调,能有效消除齿轮间隙带来的定位误差。
下面这张图展示了完整的爬山对焦流程:
3.2 窗口选择策略:别让背景“带偏”了焦点
评价函数算的是整个窗口内的清晰度。如果窗口选得太大,背景的纹理就会干扰主体判断。我见过一个典型案例:用户拍一朵花,背景是草地,结果镜头对到了草叶上,花反而是虚的。为什么?因为草地的纹理密度比花瓣高,评价函数被“骗”了。
窗口选择有几种主流策略:
| 策略 | 原理 | 适用场景 | 我踩过的坑 |
|---|---|---|---|
| 中心固定窗口 | 取画面中央 1/4 ~ 1/9 区域 | 人像、静物(主体居中) | 拍合影时边缘人脸全虚 |
| 多区域加权 | 划分 3×3 或 5×5 网格,中心权重高 | 通用场景,兼顾边缘 | 权重调不好,反而更糟 |
| 人脸/物体跟踪 | 检测到人脸后,窗口跟随人脸 | 人像、视频通话 | 侧脸时窗口漂移 |
| 自适应窗口 | 根据场景内容动态调整大小 | 复杂场景 | 计算量大,容易掉帧 |
我的建议:量产项目中,我倾向于“中心固定 + 人脸检测”双模切换。没有人脸时用中心窗口,检测到人脸后自动切换到人脸框。这样既保证了性能,又兼顾了用户体验。
窗口大小也有讲究。太小了,容易受局部噪声影响;太大了,背景干扰严重。我做过一组对比实验:
- 窗口占全图 1/16:对焦速度快,但容易跑焦,尤其是有纹理重复的场景。
- 窗口占全图 1/4:稳定性最好,但对焦速度略慢。
- 窗口占全图 1/2 以上:基本不可用,背景干扰太严重。
嗯,这里要注意——窗口的纵横比最好和图像保持一致。我曾经图省事用了固定正方形窗口,结果在 16:9 的画幅下,窗口覆盖了太多天空区域,对焦直接崩了。
3.3 评价函数设计:Tenengrad、Laplacian、Brenner 怎么选?
评价函数是 CDAF 的“眼睛”。它负责给每一帧图像打一个清晰度分数。常用的有三种:Tenengrad、Laplacian 和 Brenner。它们各有脾气,选错了会出大问题。
3.3.1 Tenengrad:基于 Sobel 梯度的“老大哥”
Tenengrad 用 Sobel 算子计算水平和垂直方向的梯度,然后求和。公式不复杂:
// Tenengrad 评价函数(简化实现)
float tenengrad_score(uint8_t* img, int width, int height) {
float score = 0.0f;
int sobel_x[3][3] = {{-1, 0, 1}, {-2, 0, 2}, {-1, 0, 1}};
int sobel_y[3][3] = {{-1, -2, -1}, {0, 0, 0}, {1, 2, 1}};
for (int y = 1; y < height - 1; y++) {
for (int x = 1; x < width - 1; x++) {
int gx = 0, gy = 0;
for (int ky = -1; ky <= 1; ky++) {
for (int kx = -1; kx <= 1; kx++) {
int pixel = img[(y + ky) * width + (x + kx)];
gx += pixel * sobel_x[ky + 1][kx + 1];
gy += pixel * sobel_y[ky + 1][kx + 1];
}
}
score += sqrtf(gx * gx + gy * gy);
}
}
return score;
}
Tenengrad 的优点是稳定,对噪声不敏感。缺点是计算量大——每个像素要做 18 次乘加。我在项目中通常只在粗搜阶段用 Tenengrad,细搜阶段换更轻量的算子。
3.3.2 Laplacian:边缘检测的“快刀手”
Laplacian 用二阶微分检测边缘,对焦平面附近的响应更尖锐。说白了,它比 Tenengrad 更“敏感”。
// Laplacian 评价函数(简化实现)
float laplacian_score(uint8_t* img, int width, int height) {
float score = 0.0f;
int laplacian[3][3] = {{0, -1, 0}, {-1, 4, -1}, {0, -1, 0}};
for (int y = 1; y < height - 1; y++) {
for (int x = 1; x < width - 1; x++) {
int val = 0;
for (int ky = -1; ky <= 1; ky++) {
for (int kx = -1; kx <= 1; kx++) {
val += img[(y + ky) * width + (x + kx)] * laplacian[ky + 1][kx + 1];
}
}
score += abs(val);
}
}
return score;
}
Laplacian 的优点是峰值尖锐,定位精度高。但缺点也很明显——对噪声极度敏感。我曾经在暗光场景下用 Laplacian,分数曲线抖得像心电图,爬山算法根本没法收敛。后来我加了高斯预滤波,才勉强能用。
3.3.3 Brenner:移动端最爱的“轻量级选手”
Brenner 只计算相隔两个像素的灰度差,计算量极小。公式简单到离谱:
// Brenner 评价函数(简化实现)
float brenner_score(uint8_t* img, int width, int height) {
float score = 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];
score += diff * diff; // 平方放大差异
}
}
return score;
}
Brenner 的优点是快,非常适合移动端实时计算。缺点是精度一般,对纹理稀疏的场景不敏感。我一般在预览流(30fps)中用 Brenner 做粗搜,配合 Tenengrad 做细搜,效果还不错。
避坑指南:我曾经在 Brenner 的实现中直接用值代替平方,结果分数曲线太平缓,峰值不明显。后来改成平方后,曲线陡峭度提升了 40%。记住——平方操作虽然多了一次乘法,但对区分度提升很大,这笔账值得算。
3.4 三种评价函数的对比与选型建议
我把三种函数的核心差异整理成了一张表,方便你快速决策:
| 指标 | Tenengrad | Laplacian | Brenner |
|---|---|---|---|
| 计算量 | 高(18 次乘加/像素) | 中(9 次乘加/像素) | 低(1 次减法 + 1 次乘法/像素) |
| 峰值尖锐度 | 中等 | 高 | 低 |
| 抗噪能力 | 强 | 弱 | 中等 |
| 适用阶段 | 粗搜 + 细搜 | 细搜(需预滤波) | 粗搜(实时预览) |
| 移动端适配 | 需 NEON 优化 | 需高斯预滤波 | 直接可用 |
我个人习惯的搭配方案是:粗搜用 Brenner,细搜用 Tenengrad,特殊场景(如微距)切 Laplacian。这样既保证了速度,又兼顾了精度。当然,如果你的芯片算力足够,也可以全程用 Tenengrad,省去切换的麻烦。
最后说一句——评价函数没有的“最好”,只有“最合适”。你在实际项目中,一定要结合自己的镜头模组、马达特性和场景分布来做选择。别盲目照搬里的方案,那玩意儿在实验室里跑得欢,上了手机可能就翻车。