OpenCV-Python实战(30)——基于支持向量机的交通标志识别模型
0. 前言
我们之前已经学习了如何通过关键点和特征来描述物体,以及如何在同一个物理物体的两幅不同图像中找到对应点。然而,在现实环境中识别物体并将其归入概念类别时,我们之前的方法相当受限。例如,在《使用 Kinect 深度传感器进行手势识别》中,图像中的目标物体是一只手,而且它必须正好位于屏幕中央,我们的目标是消除这些限制。
本节的目的是训练一个多类分类器以识别交通标志。在本节中,我们将学习如何将机器学习模型应用于实际问题,如何使用已有的数据集来训练模型,还将学习如何使用支持向量机 (Support Vector Machine, SVM) 进行多类分类,以及如何训练、测试和改进 OpenCV 提供的机器学习算法,以实现实际任务。
我们将训练一个 SVM 来识别各种交通标志。尽管 SVM 是二类分类器(即它们最多只能学习两个类别——正例和负例、动物和非动物等),但可以将其扩展用于多类分类。为了获得良好的分类性能,我们将探索多种颜色空间以及方向梯度直方图 (Histogram of Oriented Gradient, HOG) 特征。最终的结果将是一个能够以非常高的准确率区分数据集中 40 多种不同标志的分类器。
当我们希望让自己的视觉相关应用更加智能时,学习机器学习的基础知识将非常有用。本节将介绍机器学习的基础知识,后续学习将在此基础上进一步展开。
1. 规划应用程序
要构建一个能够区分数据集中 40 多种不同的标志的多类别分类器,我们需要执行以下步骤:
- 预处理数据集:我们需要一种方法来加载数据集、提取感兴趣区域,并将数据划分为适当的训练集和测试集
- 提取特征:原始像素值很可能不是数据最具信息量的表示形式。我们需要一种从数据中提取有意义特征的方法,例如基于不同颜色空间和
HOG的特征 - 训练分类器:我们将使用一对多 (
one-versus-all) 策略在训练数据上训练多类分类器 - 评估分类器:我们将通过计算不同的性能指标(如准确率、精确率和召回率)来评估训练好的集成分类器的质量。
我们将在接下来的部分中详细讨论这些步骤。
最终的应用程序将解析数据集、训练集成分类器、评估其分类性能并可视化结果。这需要以下组件:
main:main函数例程(在recognize_traffic_signs.py中)用于启动应用程序datasets.gtsrb:用于解析GTSRB数据集的脚本。该脚本包含以下函数:load_data:该函数用于加载GTSRB数据集、提取所选特征,并将数据划分为训练集和测试集_featurize,hog_featurize:这些函数被传递给load_data,用于从数据集中提取所选特征。示例函数如下:gray_featurize:该函数基于灰度像素值创建特征。surf_featurize:该函数基于加速鲁棒特征 (Speeded-Up-Robust Feature,SURF) 创建特征。
分类性能将根据准确率、精确率和召回率来评判。
2. 监督学习概念简介
监督学习是机器学习的一个重要子领域。在监督学习中,我们尝试从一组带标签的数据中学习——也就是说,每个数据样本都有一个期望的目标值或真实输出值。这些目标值可以对应于函数的连续输出(例如
y
=
s
i
n
(
x
)
y = sin(x)
y=sin(x) 中的
y
y
y),也可以对应于更抽象的离散类别(例如猫或狗)。
监督学习算法使用已经带标签的训练数据,对其进行分析,并产生一个从特征到标签的推断映射函数,该函数可用于映射新的示例。理想情况下,推断出的算法能够很好地泛化,并为新数据给出正确的目标值。
我们将监督学习任务分为两类:
- 如果处理的是连续输出(例如降雨概率),这个过程称为回归 (
regression) - 如果处理的是离散输出(例如动物的物种),这个过程称为分类 (
classification)
在本节中,我们专注于对 GTSRB 数据集的图像进行分类,并将使用一种称为支持向量机 (Support Vector Machine, SVM) 的算法来推断图像与其标签之间的映射函数。
首先让我们理解机器学习是如何赋予机器像人类一样的学习能力的。
2.1 训练过程
举个例子,我们可能想学习猫和狗长什么样。要将其设定为一个监督学习任务,首先必须把它表述为一个具有类别答案或实数值答案的问题。
以下是一些示例问题:
- 给定图片中显示的是哪种动物?
- 图片中是否有猫?
- 图片中是否有狗?
之后,我们必须收集带有相应正确答案的示例图片——即训练数据。然后,我们需要选择一个学习算法(模型),并以某种方式开始调整其参数(优化器),使得模型在面对训练数据中的某个数据点时能够给出正确答案。
我们重复这个过程,直到对模型在训练数据上的表现或得分(可以是准确率、精确率或其他损失函数)感到满意为止。如果不满意,我们就改变模型的参数以便逐渐提高得分。
这个过程如下图所示:

在上图中,训练数据由一组特征表示。对于现实生活中的分类任务,这些特征很少是图像的原始像素值,因为原始像素值往往不能很好地表示数据。通常,找到最能描述数据的特征的过程是整个学习任务中至关重要的一部分(也称为特征选择或特征工程)。这就是为什么在考虑建立分类器之前,深入研究所使用的训练集的统计特性总是一个好主意。
我们可能已经意识到,现有的模型、损失函数和优化器种类繁多,它们构成了学习过程的核心。模型(例如线性分类器或 SVM )定义了如何将输入特征转换为损失函数(例如均方误差),而优化器(例如梯度下降)则定义了模型的参数如何随时间变化。
分类任务中的训练过程也可以看作是寻找一个合适的决策边界,这是一条能够最好地将训练集划分为两个子集的线,每个子集对应一个类别。例如,考虑只有两个特征(
x
x
x 值和
y
y
y 值)以及相应类别标签(正 (+) 或负 (–) )的训练样本。
在训练过程开始时,分类器试图画一条线将所有正例与所有负例分开。随着训练的进行,分类器看到越来越多的数据样本,这些样本用于更新决策边界,如下图所示:

与这个简单的图示相比,SVM 试图在高维空间中寻找最优的决策边界,因此决策边界可以比一条直线复杂得多。
接下来,我们继续理解测试过程。
2.2 测试过程
为了使训练好的分类器具有任何实用价值,我们需要知道它在面对从未见过的数据样本(也称为泛化)时的表现。沿用之前的例子,当我们向分类器展示一张从未见过的猫或狗的照片时,我们想知道分类器会预测出哪个类别。
更一般地说,我们希望知道在下图中,基于我们在训练阶段学到的决策边界,问号 (?) 对应的应该是哪个类别:

从上图可以看出为什么这是一个棘手的问题。如果问号 (?) 的位置更靠左一些,我们就能确定其对应的类别标签是正 (+)。
然而,在这种情况下,有多种画决策边界的方式,使得所有正号 (+) 在边界左侧,所有负号 (–) 在边界右侧,如下图所示:

因此,问号 (?) 的标签取决于训练过程中得出的具体决策边界。如果上图中的问号 (?) 实际上是一个负号 (–),那么只有一条决策边界(最左边的那条)能得到正确答案。一个常见的问题是:训练可能产生一个在训练集上效果过好(也称为过拟合)的决策边界,但在应用于未见过的数据时会犯很多错误。
在这种情况下,模型很可能将训练集特有的细节刻印到了决策边界上,而不是揭示出数据的通用属性(这些属性也可能适用于未见过的数据)。
正则化是一种减少过拟合影响的常用技术。
问题归根结底在于找到一条最佳边界,它不仅能最好地划分训练集,也能最好地划分测试集。这就是为什么分类器最重要的指标是其泛化性能(即它在分类训练阶段未见过的数据时的表现)。
为了将我们的分类器应用于交通标志识别,我们需要一个合适的数据集。在本节中,我们选择 GTSRB 数据集。
3. GTSRB 数据集
GTSRB数据集包含超过5万张交通标志图像,共分为43个类别。GTSRB数据集非常适合我们的需求,因为它规模庞大、结构清晰、开源且带有标注信息。首先需要下载 GTSRB 数据集。
尽管实际的交通标志不一定呈正方形或位于图像正中心,但该数据集附带的标注文件为每个标志提供了边界框信息。
在进行机器学习任务之前,通常建议先对数据集的特性、优势及挑战建立直观认识。有效的方法包括:手动浏览数据以理解其特征、查阅数据说明文档(如果页面提供)以判断哪些模型可能效果更佳等。
以下代码片段来自 data/gtsrb.py,它负责加载训练集并随机抽取 15 个样本进行可视化,重复 100 次,以便分页查看数据:
if __name__ == '__main__':
train_data, train_labels = load_training_data(labels=None)
for _ in range(100):
indices = np.arange(len(train_data))
np.random.shuffle(indices)
for r in range(3):
for c in range(5):
i = 5 * r + c
ax = plt.subplot(3, 5, 1 + i)
sample = train_data[indices[i]]
ax.imshow(cv2.resize(sample, (32, 32)), cmap=cm.Greys_r)
ax.axis('off')
plt.tight_layout()
plt.show()
np.random.seed(np.random.randint(len(indices)))
另一个有效策略是:从全部 43 个类别中各随机抽取 15 个样本,观察同一类别内图像的差异。下图展示了该数据集的一些示例:

即便仅观察这一小部分样本,也能明显看出:这一分类任务极具挑战性。标志的外观会因拍摄角度(方向)、拍摄距离(模糊程度)及光照条件(阴影与高光)而发生剧烈变化。其中某些标志,即便是人类也很难立刻判断出正确类别。
接下来,我们将学习解析该数据集,并将其转换为适合支持向量机 (Support Vector Machine, SVM) 训练使用的格式。
3.1 解析数据集
GTSRB 数据集包含 21 个可供下载的文件。为了更具教学意义,我们选择使用原始数据:下载官方训练数据(图像与标注,GTSRB_Final_Training_Images.zip )用于训练,以及 IJCNN 2011 竞赛使用的官方训练数据集(图像与标注,GTSRB-Training_fixed.zip )用于评分。下图展示了数据集中的文件:

我们选择分别下载训练数据和测试数据,而不是从某个数据集中自行划分训练/测试集。这是因为在探索数据后发现,同一个交通标志通常有约 30 张不同距离拍摄的极其相似的图像。如果将这 30 张图像分散到不同的数据集中,会导致问题偏差,即便我们的模型泛化能力不强,也会得到看似很好的结果。
以下函数用于下载数据:
ARCHIVE_PATH = 'https://sid.erda.dk/public/archives/daaeac0d7ce1152aea9b61d9f1e19370/'
def _download(filename, *, md5sum=None):
write_path = Path(__file__).parent / filename
if write_path.exists() and _md5sum_matches(write_path, md5sum):
return write_path
response = requests.get(f'{ARCHIVE_PATH}/{filename}')
response.raise_for_status()
with open(write_path, 'wb') as outfile:
outfile.write(response.content)
return write_path
上述代码接收一个文件名(文件名可参考前文截图),检查该文件是否已存在(如果提供了 md5sum 校验值,还会检查是否匹配)。这样可以避免反复下载文件,节省大量带宽和时间。随后,它会下载文件并存储在与代码文件相同的目录中。
下载完文件后,我们编写一个函数,利用数据自带的标注格式进行解压和提取,步骤如下:
(1) 首先,打开下载的 .zip 文件(可能是训练数据或测试数据),遍历所有文件,只打开 .csv 文件。这些 CSV 文件包含对应类别中每张图像的目标标签信息:
def _load_data(filepath, labels):
data, targets = [], []
with ZipFile(filepath) as data_zip:
for path in data_zip.namelist():
if not path.endswith('.csv'):
continue
(2) 接着,检查图像的标签是否在我们感兴趣的标签数组中。然后创建一个 csv.reader,用于遍历 CSV 文件的内容:
*dir_path, csv_filename = path.split('/')
label_str = dir_path[-1]
if labels is not None and int(label_str) not in labels:
continue
with data_zip.open(path, 'r') as csvfile:
reader = csv.DictReader(TextIOWrapper(csvfile), delimiter=';')
for img_info in reader:
(3) CSV 文件中的每一行对应一个数据样本的标注信息。因此,我们提取图像路径、读取数据并将其转换为 NumPy 数组。通常,这些样本中的目标物体并非完美裁剪,而是嵌入在周围环境中。我们利用压缩包中提供的边界框信息,并结合每个标签对应的 CSV 文件,对图像进行裁剪。在以下代码中,我们将交通标志添加到 data 中,将对应的标签添加到 targets 中:
img_path = '/'.join([*dir_path, img_info['Filename']])
raw_data = data_zip.read(img_path)
img = cv2.imdecode(np.frombuffer(raw_data, np.uint8), 1)
x1, y1 = int(img_info['Roi.X1']), int(img_info['Roi.Y1'])
x2, y2 = int(img_info['Roi.X2']), int(img_info['Roi.Y2'])
data.append(img[y1: y2, x1: x2])
targets.append(int(img_info['ClassId']))
通常情况下,可以进行某种形式的特征提取,因为原始图像数据很少能作为数据的最佳描述方式。我们将这部分工作交由另一个函数处理,后续会详细介绍该函数。
如前一小节所述,将用于训练分类器的样本与用于测试的样本分开至关重要。为此,以下代码片段展示了我们有两个不同的函数,分别用于下载训练数据和测试数据,并将其加载到内存中:
def load_test_data(labels=[0, 10]):
filepath = _download('GTSRB_Online-Test-Images-Sorted.zip',
md5sum='b7bba7dad2a4dc4bc54d6ba2716d163b')
return _load_data(filepath, labels)
def load_training_data(labels=[0, 10]):
filepath = _download('GTSRB-Training_fixed.zip',
md5sum='513f3c79a4c5141765e10e952eaa2478')
return _load_data(filepath, labels)
现在我们知道了如何将图像转换为 NumPy 矩阵,接下来,就可以将数据输入 SVM 并训练它进行预测。因此,我们进入下一小节,内容涉及特征提取。
4. 数据集特征提取
在《通过特征匹配与透视变换寻找物体》一节中,原始像素值很可能不是最具信息量的数据表示方式。相反,我们需要推导出数据的可测量属性,这些属性对分类任务更具信息量。
然而,通常并不明确哪些特征表现最佳。相反,实践者往往需要尝试他们认为合适的多种不同特征。毕竟,特征的选择可能高度依赖于待分析的具体数据集或待执行的特定分类任务。
例如,如果需要区分停车标志和警告标志,那么最显著的特征可能是标志的形状或配色方案。但如果需要在两种警告标志之间进行区分,那么颜色和形状就毫无帮助,需要设计更复杂的特征。
为了演示特征选择如何影响分类性能,我们将重点关注以下几种方法:
- 几种简单的颜色变换(如灰度图、
RGB、HSV):基于灰度图像分类可以为分类器提供基线性能。RGB可能会因为某些交通标志独特的配色方案而略好一些。HSV预计表现更佳,因为它比RGB对颜色的表示更鲁棒。交通标志往往具有非常明亮、饱和的颜色,(理想情况下)与其周围环境有明显区别 SURF:我们之前已经认识到 SURF 是从图像中提取有意义特征的一种高效且鲁棒的方法。那么,在分类任务中,我们能否利用这一技术呢?HOG:统计图像上密集网格中梯度方向的出现频率,非常适合与SVM配合使用
特征提取由 data/process.py 文件中的函数执行,我们将调用不同的函数来构建和比较不同的特征。
按照以下框架,我们将能够轻松编写自己的特征化函数,并用文中的代码进行比较,看看自定义的 your_featurize 函数是否能产生更好的结果:
def your_featurize(data: List[np.ndarry], **kwargs) -> np.ndarray:
...
*_featurize 函数接收一个图像列表,并返回一个矩阵(二维 np.ndarray),其中每一行是一个新样本,每一列代表一个特征。
对于接下来要介绍的大部分特征,我们将使用 OpenCV 中(已经比较合适的)默认参数。不过这些参数并非固定不变,在现实世界的分类任务中,常常需要在可能的取值范围内搜索特征提取和特征学习参数,这个过程称为超参数探索。
既然已经明确了要做什么,让我们来看几个我们设计的特征化函数,它们基于之前学习的相关概念,同时也引入了一些新思路。
4.1 常见的预处理方法
在介绍我们设计的特征之前,先花点时间了解两种最常见的预处理形式——它们几乎总会在开始机器学习任务之前应用于数据,即均值减法和归一化。
均值减法是最常见的预处理形式(有时也称为零中心化或去均值),它计算数据集中所有样本在每个特征维度上的均值,然后从每个样本中减去这个按特征计算的均值。我们可以把这个过程想象成将散布数据移到原点周围。
归一化是指对数据维度进行缩放,使它们具有大致相同的尺度。这可以通过两种方式实现:将每个维度除以其标准差(在零中心化之后),或者将每个维度缩放到 [-1, 1] 范围内。
只有在不同输入特征具有不同尺度或单位时,才适合应用这一步。对于图像而言,像素的相对尺度已经大致相等(且在 [0, 255] 范围内),因此并不是必须执行这个额外的预处理步骤。
掌握了这两个概念后,让我们来看看我们的特征提取器。
4.2 灰度特征
最简单的特征提取方法可能就是每个像素的灰度值。通常,灰度值并不能很好地表征数据,但为了说明问题(即获得基线性能),我们在这里将其纳入。
对于输入集中的每张图像,我们将执行以下步骤:
(1) 将所有图像调整为相同的(通常更小的)尺寸:
resized_images = (cv2.resize(x, scale_size) for x in data)
(2) 将图像转换为灰度图(像素值仍在 0-255 范围内):
gray_data = (cv2.cvtColor(x, cv2.COLOR_BGR2GRAY) for x in resized_images)
(3) 将每张图像的像素值转换到 (0,1) 范围内并展平,这样每张图像就不再是 (32, 32) 的矩阵,而是大小为 1024 的向量:
scaled_data = (np.array(x).astype(np.float32).flatten() / 255
for x in gray_data)
(4) 减去展平后向量的平均像素值:
return np.vstack([x - x.mean() for x in scaled_data])
我们使用返回的矩阵作为机器学习算法的训练数据。
4.3 色彩空间
颜色包含一些原始灰度值无法捕捉的信息。交通标志通常具有独特的配色方案,这可能暗示着它试图传达的信息(例如,红色表示禁止性标志;绿色表示信息性标志;等等)。
然而,即使是 RGB 也可能不够信息丰富。例如,一个在晴朗白天的停车标志可能看起来非常明亮清晰,但在雨天或雾天,它的颜色可能显得暗淡得多。一个更好的选择是 HSV 颜色空间,它使用色调、饱和度和明度(或亮度)来表示颜色。
在这个颜色空间中,交通标志最具说明性的特征可能是色调(一种更具感知相关性的颜色或色度描述),它能更好地区分不同标志类型的配色方案。不过,饱和度和明度可能同样重要,因为交通标志往往使用相对明亮、饱和的颜色,这些颜色通常不会出现在自然场景(即它们的周围环境)中。
在 OpenCV 中,只需调用 cv2.cvtColor 即可转换到 HSV 颜色空间:
hsv_data = (cv2.cvtColor(x, cv2.COLOR_BGR2HSV) for x in resized_images)
特征化过程与灰度特征几乎相同。对于每张图像,我们执行以下四个步骤:
- 将所有图像调整为相同的(通常更小的)尺寸
- 将图像转换为
HSV(像素值在0-255范围内) - 将每张图像的像素值转换到
(0,1)范围内,并展平 - 减去展平后向量的平均像素值
接下来,让我们尝试一个更复杂的特征提取器示例,SURF。
4.4 SURF 描述符
在《通过特征匹配与透视变换寻找物体》一节中,我们了解到 SURF 描述符是一种最优且最鲁棒的图像描述方法,并且与尺度和旋转无关。那么,在分类任务中,我们能否利用这一技术呢?
要实现这一目标,我们需要调整 SURF,使其为每张图像返回固定数量的特征。默认情况下,SURF 描述符仅应用于图像中一小部分感兴趣的关键点,而这些关键点的数量可能因图像而异。这不符合我们当前的目的,因为我们希望每个数据样本都有固定数量的特征值。
我们需要将 SURF 应用于图像上铺设的固定密集网格。为此,我们创建一个包含所有像素的关键点数组:
def surf_featurize(data, *, scale_size=(16, 16), num_surf_features=100):
all_kp = [cv2.KeyPoint(float(x), float(y), 1)
for x, y in itertools.product(range(scale_size[0]),
range(scale_size[1]))]
然后,我们可以为网格上的每个点获取 SURF 描述符,并将这些数据样本追加到特征矩阵中,用 hessianThreshold 值为 400 来初始化 SURF:
surf = cv2.xfeatures2d_SURF.create(hessianThreshold=400)
最后,可以通过以下代码获取关键点和描述符:
kp_des = (surf.compute(x, all_kp) for x in data)
由于 surf.compute 有两个输出参数,kp_des 实际上会是关键点和描述符的拼接结果。kp_des 数组中的第二个元素就是我们关心的描述符。
我们从每个数据样本中选择前 num_surf_features 个特征,并将其作为该图像的特征返回:
return np.array([d.flatten()[:num_surf_features]
for _, d in kp_des]).astype(np.float32)
4.5 HOG描述符
最后一个要考虑的特征描述符是 HOG。先前的研究表明,HOG 特征与 SVM 结合使用时效果极佳,尤其是在行人识别等任务中。
HOG 特征的核心思想是:图像中物体的局部形状和外观可以通过边缘方向的分布来描述。图像被划分为多个小的连通区域(称为细胞单元),在每个区域内,会统计梯度方向(或边缘方向)的直方图。
下图展示了图像中某个区域的直方图示例。角度是没有方向性的,因此范围是 (-180, 180):

可以看到,该区域在水平方向(角度接近 +180 和 -180 度)上有大量的边缘方向,因此这看起来是一个很好的特征,尤其是在处理箭头和线条时。
然后,通过拼接这些不同的直方图来组装成最终的描述符。为了提升性能,可以对局部直方图进行对比度归一化,从而对光照和阴影变化具有更好的不变性。可以看出,这种预处理方式在识别不同视角和光照条件下的交通标志时可能非常合适。
在 OpenCV 中,可以通过 cv2.HOGDescriptor 方便地使用 HOG 描述符。该函数接收检测窗口大小 (32×32)、块大小 (16×16)、细胞单元大小 (8×8) 和细胞单元步长 (8×8) 作为输入参数。对于每个细胞单元,HOG 描述符会使用 9 个方向梯度直方图区间 (bins) 来计算 HOG 特征:
def hog_featurize(data, *, scale_size=(32, 32)):
block_size = (scale_size[0] // 2, scale_size[1] // 2)
block_stride = (scale_size[0] // 4, scale_size[1] // 4)
cell_size = block_stride
hog = cv2.HOGDescriptor(scale_size, block_size, block_stride,
cell_size, 9)
resized_images = (cv2.resize(x, scale_size) for x in data)
return np.array([hog.compute(x).flatten() for x in resized_images])
将 HOG 描述符应用于每个数据样本,只需调用 hog.compute 即可。在我们提取完所有需要的特征后,为每张图像返回一个展平后的特征列表。
至此,我们终于可以在预处理后的数据集上训练分类器了。接下来,让我们继续学习 SVM。
5. 支持向量机
支持向量机 (Support Vector Machine, SVM) 是一种用于二分类(以及回归)的学习器,它试图通过一个决策边界将两个不同类别的样本分开,并且这个决策边界能够最大化两个类别之间的间隔。
让我们回到正样本和负样本的例子,每个样本恰好有两个特征(
x
x
x 和
y
y
y),以及两种可能的决策边界,如下图所示:

这两种决策边界都能完成任务——它们将所有正样本和负样本正确分开,零分类错误。然而,凭直觉其中一个看起来更好。我们该如何量化“更好”,从而学习到最佳参数设置呢?
这正是 SVM 发挥作用的地方。SVM 也被称为最大间隔分类器,因为它恰恰可以用来做到这一点——定义决策边界,使得正负两类数据云团尽可能远离彼此,也就是尽可能远离决策边界。
对于上面的例子,SVM 会找到两条平行线,它们分别穿过类别边缘上的数据点(下图中虚线所示),然后将穿过间隔中心的那条线作为决策边界(下图中加粗的黑线):

事实证明,要找到最大间隔,只需关注位于类别边缘上的数据点即可。这些点有时也被称为支持向量。
除了执行线性分类(即决策边界为直线)之外,SVM 还可以使用所谓的核技巧执行非线性分类,将输入隐式地映射到高维特征空间。
现在,让我们来看看如何将这个二分类器转换为多分类器,使其更适合我们正在解决的 43 类分类问题。
5.1使用 SVM 进行多分类
虽然某些分类算法(如神经网络)天然支持多于两个类别,但 SVM 本质上是二分类器。不过,它们可以被转换为多分类器。这里我们将考虑两种不同的策略:
- 一对多 (
One-versus-all):一对多策略是为每个类别训练一个单独的分类器,将该类别的样本作为正样本,所有其他类别的样本作为负样本。
对于k个类别,这种策略需要训练k个不同的SVM。在测试时,所有分类器都可以通过预测未见过的样本属于其自身类别来表达+1投票。
最终,集成分类器将未见样本归类为得票最多的类别。通常,这种策略会结合置信度分数而不是预测标签来使用,这样最终可以选择置信度最高的类别。 - 一对一 (
One-versus-one):一对一策略是为每对类别训练一个单独的分类器,将第一个类别的样本作为正样本,第二个类别的样本作为负样本。对于k个类别,这种策略需要训练k×(k-1)/2个分类器。
然而,每个分类器需要解决的任务要简单得多,因此在选择使用哪种策略时需要权衡取舍。在测试时,所有分类器可以对第一个或第二个类别投出+1票。最终,集成分类器将未见样本归类为得票最多的类别。
通常,除非我们真的想深入钻研算法并将模型的性能压榨到极致,否则不必自己编写分类算法,OpenCV 本身就自带了一个优秀的机器学习工具包,本节我们将使用它。OpenCV 采用一对多的策略,我们也将重点关注这一策略。
接下来,让我们动手实践,看看如何用 OpenCV 编写代码并展示实际结果。
5.2 训练 SVM
我们将把训练方法写在一个独立的函数中,便于以后更改训练方法。首先,定义函数的签名如下:
def train(training_features: np.ndarray, training_labels: np.ndarray):
因此,我们希望这个函数接收两个参数:training_features (训练特征)和 training_labels (与每个特征对应的正确答案)。其中,第一个参数是一个二维 NumPy 数组形式的矩阵,第二个参数是一个一维 NumPy 数组。
然后,该函数将返回一个对象,该对象应具有 predict 方法,能够接收新的未见数据并给出标签。那么,让我们开始看看如何用 OpenCV 训练一个 SVM。
我们将函数命名为 train_one_vs_all_SVM,并执行以下步骤:
(1) 使用 cv2.ml.SVM_create 实例化一个 SVM 类实例,这将创建一个使用一对多策略的多类 SVM:
def train_one_vs_all_SVM(X_train, y_train):
single_svm = cv2.ml.SVM_create()
(2) 设置优化器的超参数。之所以称为超参数,是因为这些参数不受优化器自身的控制(与优化器在学习过程中变化的参数不同):
single_svm.setKernel(cv2.ml.SVM_LINEAR)
single_svm.setType(cv2.ml.SVM_C_SVC)
single_svm.setC(2.67)
single_svm.setGamma(5.383)
(3) 调用 SVM 实例的 train 方法,OpenCV 会负责训练:
single_svm.train(X_train, cv2.ml.ROW_SAMPLE, y_train)
return single_svm
OpenCV 会处理其余部分。底层的原理是:SVM 训练过程使用拉格朗日乘子法来优化某些约束条件,从而得到最大间隔的决策边界。
优化过程通常会持续进行,直到满足某个终止条件,这些条件可以通过 SVM 的可选参数来指定。
现在我们已经了解了如何训练 SVM,接下来看看如何测试它。
5.3 测试 SVM
评估分类器的方法有很多,但大多数情况下,我们最关心的是准确率指标——即测试集中有多少数据样本被正确分类。
为了得到这个指标,我们需要从 SVM 获取预测结果,OpenCV 提供了 predict 方法,该方法接收特征矩阵并返回预测标签数组。因此,我们需要按以下步骤进行:
(1) 首先,对测试数据进行特征化处理:
x_train = featurize(train_data)
(2) 然后将特征化后的数据输入分类器,获得预测标签:
res = model.predict(x_test)
y_predict = res[1].flatten()
(3) 之后,查看分类器正确预测了多少个标签:
num_correct = sum(y_predicted == y_true)
现在,我们已经准备好计算所需的性能指标,后续学习会详细描述。就本节而言,我们选择计算准确率、精确率和召回率。
scikit-learn 机器学习包直接支持准确率、精确率和召回率这三个指标(以及其他指标),并且还附带了许多其他有用的工具。出于学习目的,我们将自行推导这三个指标。
5.3.1 准确率
最直观的指标可能就是准确率了。该指标简单地统计被正确预测的测试样本数量,然后将其表示为测试样本总数的比例:
def accuracy(y_predicted, y_true):
return sum(y_predicted == y_true) / len(y_true)
上述代码展示了我们通过调用 model.predict(x_test) 提取了 y_predicted。这相当简单,但为了代码的可复用性,我们将其封装在一个函数中,该函数接收预测标签和真实标签作为参数。现在,我们将继续实现一些稍微复杂但对衡量分类器性能非常有用的指标。
5.3.2 混淆矩阵
混淆矩阵是一个二维矩阵,大小为 (num_classes, num_classes),其中行对应预测的类别标签,列对应真实的类别标签。那么,矩阵中的 [r, c] 元素表示被预测为标签 r、但实际标签为 c 的样本数量。有了混淆矩阵,我们就可以计算精确率和召回率。
现在,让我们实现一个非常简单的混淆矩阵计算方法。与准确率类似,我们创建一个参数相同的函数,以便于复用,步骤如下:
(1) 假设我们的标签是非负整数,我们可以通过取最大整数值并加 1 (考虑到零)来确定 num_classes:
def confusion_matrix(y_predicted, y_true):
num_classes = max(max(y_predicted), max(y_true)) + 1
(2) 接下来,实例化一个空矩阵,用于填充计数:
conf_matrix = np.zeros((num_classes, num_classes))
(3) 然后,遍历所有数据,对于每条数据,获取预测值 r 和真实值 c,并增加矩阵中对应位置的值。虽然有很多更快的方法,但没有比逐个计数更简单的了。我们使用以下代码实现:
for r, c in zip(y_predicted, y_true):
conf_matrix[r, c] += 1
(4) 在统计完训练集中的所有数据后,返回混淆矩阵:
return conf_matrix
以下是我们在 GTSRB 数据集测试数据上得到的混淆矩阵:

可以看到,大多数值都位于对角线上。这意味着乍看之下,我们的分类器表现相当不错。
从混淆矩阵中也可以轻松计算准确率。我们只需取对角线元素的总和,除以所有元素的总和,如下所示:
cm = confusion_matrix(y_predicted, y_true)
accuracy = cm.trace() / cm.sum()
需要注意的是,每个类别中的元素数量不同。每个类别对准确率的贡献也不同,下一个指标将重点关注每个类别的性能。
5.3.3 精确率
精确率用于衡量被检索到的相关实例的比例(也称为阳性预测值)。在分类任务中,真正例的数量被定义为被正确标记为属于正类别的样本数量。精确率定义为真正例数量除以所有被预测为正例的总数。换句话说,在分类器认为包含猫的测试集图片中,精确率是实际确实包含猫的图片所占的比例。
需要注意的是,这里我们有一个正标签;因此,精确率是一个每个类别的值。我们通常谈论某一类别的精确率,或者猫类别的精确率,等等。
被预测为正例的总数也可以计算为真正例与假正例之和,假正例是被错误标记为属于某个特定类别的样本数量。这时混淆矩阵就派上用场了,因为它允许我们通过以下步骤快速计算假正例和真正例的数量:
(1) 在这种情况下,我们需要修改函数参数,并添加正类别的标签:
def precision(y_predicted, y_true, positive_label):
(2) 使用混淆矩阵,计算真正例的数量,即 [positive_label, positive_label] 位置上的元素:
cm = confusion_matrix(y_predicted, y_true)
true_positives = cm[positive_label, positive_label]
(3) 现在,计算真正例和假正例的总数,即 positive_label 行上所有元素之和,因为该行表示预测的类别标签:
total_positives = sum(cm[positive_label])
(4) 最后,返回真正例与所有被预测为正例的比例:
return true_positives / total_positives
基于不同的类别,我们得到的精确率值差异很大。以下是所有 43 个类别的精确率分数直方图:

精确率较低的类别是第 30 个类别,这意味着很多其他标志被误认为是下图中所示的标志:

在这种情况下,在结冰的道路上行驶时格外小心是没问题的,但我们可能因此错过一些重要的信息。那么,让我们看看不同类别的召回率值。
5.3.4 召回率
召回率与精确率类似,它衡量的是被检索到的相关实例的比例(而精确率衡量的是被检索到的实例中相关实例的比例)。因此,它告诉我们:对于给定的正类别(例如某种交通标志),我们将会错过它的概率。
在分类任务中,假负例的数量是指那些没有被标记为属于正类别、但本应被标记为这类别的样本数量。
召回率定义为真正例数量除以真正例与假负例之和。换句话说,在所有猫的图片中,召回率是被正确识别为猫的图片所占的比例。
以下是使用真实标签和预测标签计算给定正类别召回率的方法:
(1) 函数签名与精确率函数相同,并且我们以同样的方式获取真正例数量:
def recall(y_predicted, y_true, positive_label):
cm = confusion_matrix(y_predicted, y_true)
true_positives = cm[positive_label, positive_label]
需要注意的是,真正例与假负例之和等于给定数据类别中的样本总数。
(2) 因此,我们只需统计该类别中元素的数量,即对混淆矩阵中 positive_label 列求和:
class_members = sum(cm[:, positive_label])
(3) 然后,像精确率函数一样返回这个比值:
return true_positives / class_members
现在,让我们看一下所有 43 个交通标志类别的召回率分布情况,如下图所示:

召回率的分布更加分散,其中第 21 类的召回率为 0.66。我们来看看哪个类别的值是 21:

这虽然不像在冰雪覆盖的路面上行驶那样危险,但错过前方道路上的危险弯道标志同样可能会带来严重的后果。
下一节将演示运行我们的应用程序所需的 main() 函数流程。
6. 整合所有内容
要运行我们的应用程序,需要执行 main 函数流程(位于 recognize_traffic_signs.py 中)。该流程将加载数据、训练分类器、评估其性能并可视化结果:
(1) 首先,导入所有相关模块并设置 main 函数:
import cv2
import numpy as np
import matplotlib.pyplot as plt
from data.gtsrb import load_training_data
from data.gtsrb import load_test_data
from data.process import surf_featurize, hog_featurize
from data.process import hsv_featurize, grayscale_featurize
(2) 然后,目标是比较不同特征提取方法的分类性能。这包括使用一系列不同的特征提取方法来运行任务。因此,我们先加载数据,然后对每种特征化函数重复该过程:
def main(labels=[0, 10, 20, 30, 40]):
train_data, train_labels = load_training_data(labels)
test_data, test_labels = load_test_data(labels)
y_train = np.array(train_labels)
y_test = np.array(test_labels)
accuracies = {}
for featurize in [hog_featurize, grayscale_featurize,
hsv_featurize, surf_featurize]:
对于每一种特征化函数,我们执行以下步骤:
(2.1) 对数据进行特征化处理,得到特征矩阵:
x_train = featurize(train_data)
(2.2) 使用 train_one_vs_all_SVM 方法训练模型:
model = train_one_vs_all_SVM(x_train, y_train)
(2.3) 通过对测试数据进行特征化处理,并传递给 predict 方法,来预测测试数据的标签(我们必须单独对测试数据进行特征化,以确保没有信息泄露):
x_test = featurize(test_data)
res = model.predict(x_test)
y_predict = res[1].flatten()
(2.4) 使用 accuracy 函数,将预测标签与真实标签进行评分,并将分数存储在字典中,以便在获得所有特征化函数的结果后进行绘图:
accuracies[featurize.__name__] = float(accuracy(y_predict, y_test))
(3) 绘制结果选择 matplotlib 的条形图。同时,我们还会适当缩放条形图,以便直观地理解差异的程度。由于准确率是介于 0 和 1 之间的数值,我们将 y 轴限制在 [0, 1] 范围内:
plt.bar(list(accuracies.keys()), list(accuracies.values()), color='skyblue', edgecolor='black')
# plt.axes().xaxis.set_tick_params(rotation=20)
plt.ylim([0, 1])
(4) 我们为图表添加一些格式美化,包括旋转横轴标签、添加网格和标题:
plt.grid()
plt.title('Test accuracy for different featurize functions')
plt.show()
在执行最后一行 plt.show() 之后,如下图所示:

从结果可以看出,hog_featurize 在该数据集上表现最佳,但远未达到完美——准确率略高于 95%。为了了解可能达到的最佳结果,可以快速搜索一下,会发现许多论文实现了 99% 以上的准确率。尽管我们的结果并非最前沿,但使用现成的分类器和简单的特征化函数,我们已经做得相当不错了。
另外值得注意的是:尽管我们原本认为颜色鲜艳的交通标志应该使 hsv_featurize (比灰度特征更重要)表现更好,但结果并非如此。
因此,一个重要的经验是:我们应该对数据进行实验,以培养更好的直觉,了解哪些特征对数据有效,哪些无效。
接下来,让我们使用神经网络来提升所得结果的效率。
7. 用神经网络改进结果
如果我们使用以下“并非那么深”的神经网络,我们可以获得约 0.964 的准确率。
以下是训练方法的代码片段:
def train_tf_model(X_train, y_train):
model = tf.keras.models.Sequential([
tf.keras.layers.Conv2D(20, (8, 8),
input_shape=list(UNIFORM_SIZE) + [3],
activation='relu'),
tf.keras.layers.MaxPooling2D(pool_size=(4, 4), strides=4),
tf.keras.layers.Dropout(0.15),
tf.keras.layers.Flatten(),
tf.keras.layers.Dense(64, activation='relu'),
tf.keras.layers.Dropout(0.15),
tf.keras.layers.Dense(43, activation='softmax')
])
model.compile(optimizer='adam',
loss='sparse_categorical_crossentropy',
metrics=['accuracy'])
model.fit(x_train, np.array(train_labels), epochs=2)
return model
该代码使用 TensorFlow 的高级 Keras API (我们将在后续学习中看到更多相关内容),并创建了一个具有以下结构的神经网络:
- 带有最大池化的卷积层,后接一个
Dropout层——该层仅在训练期间存在 - 隐藏的全连接层,后接一个
Dropout层——该层仅在训练期间存在 - 最终的全连接层,输出最终结果;它应识别输入数据属于哪个类别(共
43类)
需要注意的是,我们只有一个卷积层,这与 HOG 特征化非常相似。如果我们添加更多卷积层,性能将大幅提升,但我们将此留待后续学习探索。
小结
在本节中,我们训练了一个多类分类器来识别 GTSRB 数据库中的交通标志。我们讨论了监督学习的基础知识,探索了特征提取的复杂性,并初步了解了神经网络。通过本节的方法,我们应该能够将实际问题表述为机器学习模型,利用 Python 技能下载带标签的样本数据集,编写将图像转换为特征向量的特征化函数,并使用 OpenCV 训练现成的机器学习模型,从而解决实际问题。
值得注意的是,我们在过程中省略了一些细节,例如尝试微调学习算法的超参数。我们只关注准确率分数,并没有尝试组合所有不同的特征集来进行深入的特征工程。
通过这套功能性框架和对底层方法的良好理解,现在可以对整个 GTSRB 数据集进行分类,获得高于 97% 的准确率。
系列链接
OpenCV-Python实战(1)——OpenCV简介与图像处理基础
OpenCV-Python实战(2)——图像与视频文件的处理
OpenCV-Python实战(3)——OpenCV中绘制图形与文本
OpenCV-Python实战(4)——OpenCV常见图像处理技术
OpenCV-Python实战(5)——OpenCV图像运算
OpenCV-Python实战(6)——OpenCV中的色彩空间和色彩映射
OpenCV-Python实战(7)——直方图详解
OpenCV-Python实战(8)——直方图均衡化
OpenCV-Python实战(9)——OpenCV用于图像分割的阈值技术
OpenCV-Python实战(10)——OpenCV轮廓检测
OpenCV-Python实战(11)——OpenCV轮廓检测相关应用
OpenCV-Python实战(12)——一文详解AR增强现实
OpenCV-Python实战(13)——OpenCV与机器学习的碰撞
OpenCV-Python实战(14)——人脸检测详解
OpenCV-Python实战(15)——面部特征点检测详解
OpenCV-Python实战(16)——人脸追踪详解
OpenCV-Python实战(17)——人脸识别详解
OpenCV-Python实战(18)——深度学习简介与入门示例
OpenCV-Python实战(19)——OpenCV与深度学习的碰撞
OpenCV-Python实战(20)——OpenCV计算机视觉项目在Web端的部署
OpenCV-Python实战(21)——OpenCV人脸检测项目在Web端的部署
OpenCV-Python实战(22)——使用Keras和Flask在Web端部署图像识别应用
OpenCV-Python实战(23)——将OpenCV计算机视觉项目部署到云端
OpenCV-Python实战(24)——打造实时图像滤镜系统
OpenCV-Python实战(25)——基于深度传感器与凸性分析打造实时手势识别系统
OpenCV-Python实战(26)——复杂场景下的实时物体检测与跟踪
OpenCV-Python实战(27)——基于对极几何的3D场景重建
OpenCV-Python实战(28)——OpenCV计算摄影从HDR图像融合到全景拼接
OpenCV-Python实战(29)——基于视觉显著性的自动多目标跟踪系统
4151

被折叠的 条评论
为什么被折叠?



