2、基于梯度的评价函数:Tenengrad函数原理与实现

聊完清晰度的基本概念,咱们直接进入最常用的梯度类评价函数。说实话,我在工业相机项目里用得最多的就是 Tenengrad,它简单、有效,而且对光照变化没那么敏感。

2.1 Tenengrad 的核心思想

Tenengrad 的原理说白了就一句话:图像越清晰,边缘越锐利,梯度幅值总和越大。

它不像 Brenner 那样只算水平方向,而是同时考虑水平和垂直两个方向的梯度变化。我个人习惯把它理解成「全方向边缘能量求和」。

数学表达:

Tenengrad 值 = Σx Σy [ Gx(x,y)² + Gy(x,y)² ]

其中 Gx 和 Gy 分别是 Sobel 算子在水平和垂直方向的卷积结果。

嗯,这里要注意:平方操作是为了让强边缘贡献更大,同时避免正负梯度相互抵消。

2.2 Sobel 算子与梯度计算

Sobel 算子是个 3×3 的卷积核,它比简单的差分算子多了中心加权,抗噪能力稍好一些。

方向 卷积核 作用
水平 Gx [[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]] 检测垂直边缘
垂直 Gy [[-1, -2, -1], [0, 0, 0], [1, 2, 1]] 检测水平边缘

你想想看,为什么 Sobel 的中心系数是 2 而不是 1?

因为它在做差分的同时还带了平滑效果,对噪声有一定的抑制作用。我在做精密对焦时遇到过一个问题:直接用简单差分算子,图像稍微有点噪点,评价曲线就抖得厉害。换成 Sobel 之后,曲线平滑了很多。

下面是 Tenengrad 的 Python 实现,我习惯用 OpenCV 来做:

import cv2
import numpy as np

def tenengrad_score(image):
    """
    计算 Tenengrad 清晰度评价值
    :param image: 灰度图像 (H, W)
    :return: 浮点数评分
    """
    # 计算水平和垂直梯度
    grad_x = cv2.Sobel(image, cv2.CV_64F, 1, 0, ksize=3)
    grad_y = cv2.Sobel(image, cv2.CV_64F, 0, 1, ksize=3)
    
    # 计算梯度幅值平方
    magnitude = grad_x**2 + grad_y**2
    
    # 求和得到评价值
    score = np.sum(magnitude)
    
    return score

小技巧:用 cv2.CV_64F 而不是 CV_8U,可以避免梯度值截断。我踩过这个坑——用 8 位整型算梯度,强边缘的梯度值被截断,导致评价曲线在峰值附近出现平台,对焦精度直接下降。

2.3 梯度阈值的影响

实际应用中,我们通常不会直接用所有像素的梯度总和,而是加一个阈值 T,只统计梯度幅值大于 T 的像素。

为什么要加阈值?

说白了,是为了剔除噪声和微小纹理的干扰。你想想看,如果背景有轻微的水波纹或者传感器噪声,这些弱梯度也会贡献到总分里,导致评价曲线不够尖锐。

带阈值的 Tenengrad 公式变成:

TenengradT = Σx Σy [ Gx² + Gy² ] ,其中 Gx² + Gy² > T

阈值 T 怎么选?我根据项目经验整理了一个参考表:

场景 推荐阈值范围 说明
高信噪比、强纹理场景 100 - 200 剔除微弱噪声,保留主要边缘
中等纹理、一般光照 50 - 100 平衡噪声抑制和边缘保留
低对比度、弱纹理场景 10 - 50 阈值太低会引入噪声,太高会丢失边缘
高噪声环境(如低光照) 150 - 300 优先抑制噪声,但可能损失细节

避坑指南:我曾经在一个芯片引脚检测项目里,直接把阈值设成 200,结果评价曲线在离焦区域几乎是一条直线,完全找不到峰值。后来发现是引脚表面太光滑,梯度值普遍偏低。把阈值降到 50 之后,曲线才恢复正常。

所以阈值不是越大越好,要根据实际图像的梯度分布来调。

2.4 知识体系结构

下面这张图帮你理清 Tenengrad 评价函数的完整链路:

Tenengrad 评价函数知识体系 输入灰度图像 Sobel 算子卷积 水平 Gx + 垂直 Gy Gx² + Gy² 阈值 T 筛选 求和 → 评价值 阈值 T 控制噪声与边缘的平衡,需根据实际图像梯度分布动态调整

2.5 实现中的几个细节

写代码的时候,有几个地方容易出问题:

  1. 数据类型:一定要用浮点型(64F 或 32F),整型计算会丢失精度。
  2. 边界处理:Sobel 默认的边界填充是复制边缘像素,如果你用其他填充方式,评价结果会有偏差。
  3. 归一化:不同尺寸的图像,Tenengrad 值差异很大。做对比时最好除以像素总数,得到平均梯度值。

归一化版本:

def tenengrad_normalized(image):
    grad_x = cv2.Sobel(image, cv2.CV_64F, 1, 0, ksize=3)
    grad_y = cv2.Sobel(image, cv2.CV_64F, 0, 1, ksize=3)
    magnitude = grad_x**2 + grad_y**2
    # 除以像素总数,消除图像尺寸影响
    score = np.sum(magnitude) / (image.shape[0] * image.shape[1])
    return score

我个人习惯在项目初期先用不带阈值的版本跑一遍,看看梯度值的分布范围,再根据直方图确定阈值。这样比盲目设一个值靠谱得多。