基于视觉词袋模型的图像分类算法--视觉词典构建
原理
将一张图像看作由许多基本的"视觉单词"组成的"文档",通过统计这些"视觉单词"出现的频率(即直方图),将图像转化为一个固定长度的数值向量(特征向量)。然后,使用传统的分类器(如SVM)对这些向量进行分类。
算法流程(四个关键步骤)
第一步:特征提取
- 目标:从训练集中的所有图像中提取出大量局部的、具有区分性的特征。
- 方法 :
- 通常使用密集采样或关键点检测(如SIFT、SURF、ORB等)方法,在图像上提取大量的局部图像块。
- 对每个图像块,计算其特征描述子。最经典的是SIFT描述子,它描述的是图像块内部的梯度方向分布,对光照、旋转、尺度变化具有一定的不变性。
- 最终,我们得到来自所有训练图像的海量的特征描述子集合(例如,一百万个小向量)。每个描述子可以看作一个"视觉单词"的候选。
第二步:视觉词典生成
- 目标:将第一步中提取的无数个细微不同的特征描述子,归纳、聚类成一个具有代表性的、数量有限的"视觉单词"集合,即"视觉词典"。
- 方法 :
- 对所有收集到的特征描述子使用聚类算法,最常用的是K-Means聚类。
- 假设我们设定聚类数(即词典大小)为K(例如K=1000, 5000等)。K-Means算法会将这些海量特征聚合成K个簇。
- 每个簇的中心(也是一个特征向量)就定义为一个视觉单词 。所有K个视觉单词的集合就构成了视觉词典。
第三步:图像表示(向量化)
- 目标:利用上一步生成的视觉词典,将任何一张新的图像(无论是训练图像还是测试图像)表示成一个固定长度的数值向量(词袋直方图)。
- 方法 :
- 对于给定的一张图像,重复第一步,提取其特征描述子。
- 对于图像中的每一个特征描述子,在视觉词典中寻找与它最相似(如欧氏距离最近) 的视觉单词。
- 这个过程称为"量化"或"编码",即把每个局部特征分配到最近的视觉单词上。
- 统计整张图像中每个视觉单词出现的次数,形成一个K维的直方图。这个直方图就是该图像的词袋表示(BoW向量)。
- (可选)为了降低常见但无意义的单词(如"天空"、"草地"背景)的影响,常会使用TF-IDF加权技术对直方图进行修正,而不仅仅是简单计数。
第四步:分类器训练与预测
- 目标:使用图像的向量化表示来训练分类器,并对新图像进行分类。
- 方法 :
- 训练 :将训练集中所有图像的BoW向量和它们对应的类别标签(如"猫"、"狗"、"汽车")输入到一个监督分类器中(如支持向量机、朴素贝叶斯、随机森林等)。分类器学习不同类别的直方图分布模式。
- 预测 :对于一张新的测试图像,先通过第一、三步将其转化为K维的BoW向量,然后将其输入到训练好的分类器中,分类器会输出其预测的类别标签。
原理示意图(思维导图)
[训练阶段]
训练图像集 → 提取所有局部特征(SIFT等) → 聚类(K-Means) → 生成视觉词典(K个单词)
↓
每张训练图 → 提取其特征 → 用词典量化编码 → 生成词袋直方图(BoW向量) → 训练分类器(如SVM)
[测试阶段]
新测试图像 → 提取其特征 → 用同一词典量化编码 → 生成词袋直方图 → 送入训练好的分类器 → 输出类别
总结
视觉词袋模型是计算机视觉从手工特征时代走向深度学习时代的一个重要里程碑。它巧妙地借鉴了文本分析的思想,通过 "特征提取 → 聚类生成词典 → 量化统计" 的流程,将图像转换为结构化数据,从而实现了有效的图像分类。虽然已被深度学习方法(如CNN)在性能上超越,但其核心思想(局部特征聚合、中间表达)至今仍在许多高级视觉模型中有所体现。理解BoVW是理解现代计算机视觉发展的一个基础。
代码实现
导包
python
from PIL import Image
import cv2
import numpy as np
import os
from sklearn.model_selection import train_test_split
from sklearn.kernel_approximation import AdditiveChi2Sampler
import random
import math
from imutils import paths
数据集
caltech-101数据集
1. 概述
Caltech-101 是计算机视觉和机器学习领域的一个经典图像分类数据集,由加州理工学院(Caltech)的Fei-Fei Li、Marco Andreetto和Marc'Aurelio Ranzato于2003年创建。它在深度学习兴起之前,是物体识别研究的重要基准。
2. 核心数据
- 类别数量: 101个物体类别 + 1个背景类别。每个类别代表一种物体,如"飞机"、"帆船"、"大象"、"椅子"、"人脸"等。
- 图像数量: 总共约9,146张图片。
- 图像特点 :
- 每张图片大小不一,但分辨率大致在300x200像素左右。
- 每个类别的图像数量差异很大,从31张到800张不等(例如,"人脸"类别图像最多)。
- 图像中的物体通常位于画面中央,背景相对干净,姿态变化有限。这使得它比后来的数据集(如ImageNet)难度较低。
python
# 图像数据
data = []
# 图像对应的标签
labels = []
# 储存标签信息的临时变量
labels_tep = []
# 数据集的地址
# 将数据集放在自定义目录下
image_paths = list(paths.list_images('E:\\jupyterNotebook\\Hands-on-CV-main\\10classify\\caltech-101\\101_ObjectCategories\\101_ObjectCategories'))
for image_path in image_paths:
# 获取图像类别
label = image_path.split(os.path.sep)[-2]
# 读取每个类别的图像
image = cv2.imread(image_path)
# 将图像通道从BGR转换为RGB
image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
# 统一输入图像的尺寸
image = cv2.resize(image, (200,200), interpolation=cv2.INTER_AREA)
data.append(image)
labels_tep.append(label)
name2label = {}
tep = {}
for idx, name in enumerate(labels_tep):
tep[name] = idx
for idx, name in enumerate(tep):
name2label[name] = idx
for idx, image_path in enumerate(image_paths):
labels.append(name2label[image_path.split(os.path.sep)[-2]])
data = np.array(data)
labels = np.array(labels)
上面注意在Windows中的路径写法,避免无法识别出正确路径。
划分数据集
python
(x_train, X, y_train, Y) = train_test_split(data, labels,
test_size=0.4, stratify=labels, random_state=42)
(x_val, x_test, y_val, y_test) = train_test_split(X, Y,
test_size=0.5, random_state=42)
print(f"x_train examples: {x_train.shape}\n\
x_test examples: {x_test.shape}\n\
x_val examples: {x_val.shape}")
这段代码是数据集分割的标准流程
- 第一次分割(60-40分割)
python
(x_train, X, y_train, Y) = train_test_split(data, labels,
test_size=0.4, stratify=labels, random_state=42)
- 输入 : 完整数据集
data和对应的labels - 输出 :
x_train: 训练集数据 (60%)X: 剩下的数据 (40%),包含验证+测试集y_train: 训练集标签Y: 剩下的标签
- 参数说明 :
test_size=0.4: 40%作为测试+验证集,60%作为训练集stratify=labels: 保持类别分布比例相同(分层抽样)random_state=42: 随机种子,保证结果可复现
、 2. 第二次分割(40%再分成2部分)
python
(x_val, x_test, y_val, y_test) = train_test_split(X, Y,
test_size=0.5, random_state=42)
- 输入 : 上一步剩下的40%数据(
X和Y) - 输出 :
x_val: 验证集数据 (20%)x_test: 测试集数据 (20%)y_val: 验证集标签y_test: 测试集标签
- 效果: 将40%的数据对半分割,各得20%
- 最终分割比例
-
训练集: 60% (用于模型训练)
-
验证集: 20% (用于超参数调优)
-
测试集: 20% (用于最终评估)
原始数据集 (100%)
↓ 分割为 60-40
训练集 (60%) + 临时集 (40%)
↓ 临时集再分割为 50-50
验证集 (20%) + 测试集 (20%)最终:
训练集: 60%
验证集: 20%
测试集: 20%
视觉词典构建
我们先进行视觉词典的构建。为了得到视觉词典,首先需要对输入图像的表征进行提取,这里使用前面我们详细介绍的SIFT对图像进行处理。这里我们直接使用opencv里面的SIFT。
python
# 构建一个词典,储存每一个类别的sift信息
vec_dict = {i:{'kp':[], 'des':{}} for i in range(102)}
sift = cv2.SIFT_create()
for i in range(x_train.shape[0]):
# 对图像正则化
tep = cv2.normalize(x_train[i], None, 0, 255,
cv2.NORM_MINMAX).astype('uint8')
# 计算图像的SIFT特征
kp_vector, des_vector = sift.detectAndCompute(tep, None)
# 特征点和描述符信息储存进词典中
vec_dict[y_train[i]]['kp'] += list(kp_vector)
for k in range(len(kp_vector)):
# des使用kp_vector将其一一对应
vec_dict[y_train[i]]['des'][kp_vector[k]] = des_vector[k]
这段代码用于为每个类别提取并存储图像的SIFT特征。
- 初始化数据结构
python
vec_dict = {i:{'kp':[], 'des':{}} for i in range(102)}
- 创建包含102个类别的字典(对应102个分类)
- 每个类别是一个字典,包含两个键:
'kp':存储关键点(keypoints)的列表'des':存储描述符(descriptors)的字典,以关键点为键
- SIFT特征提取器
python
sift = cv2.SIFT_create()
- 创建SIFT特征检测器对象
- 遍历所有训练图像
python
for i in range(x_train.shape[0]):
- 对训练集中的每一张图片进行处理
x_train[i]:第i张训练图像y_train[i]:第i张图像的标签(类别编号)
- 图像预处理
python
tep = cv2.normalize(x_train[i], None, 0, 255, cv2.NORM_MINMAX).astype('uint8')
- 归一化处理:将像素值缩放到0-255范围
- 转换为
uint8类型(OpenCV要求格式)
- 提取SIFT特征
python
kp_vector, des_vector = sift.detectAndCompute(tep, None)
kp_vector:检测到的关键点列表(包含位置、尺度、方向等信息)des_vector:对应的描述符矩阵,每个关键点对应一个128维的描述向量
- 存储特征
python
# 存储关键点到对应类别的列表
vec_dict[y_train[i]]['kp'] += list(kp_vector)
# 建立关键点和描述符的对应关系
for k in range(len(kp_vector)):
vec_dict[y_train[i]]['des'][kp_vector[k]] = des_vector[k]
- 将关键点添加到该类别的
'kp'列表中 - 使用关键点作为键,将描述符存储在
'des'字典中,建立一对一的映射关系
最终生成数据实例
# 生成的字典结构示例:
vec_dict = {
# 类别0
0: {
'kp': [
# 关键点1 (cv2.KeyPoint对象)
<KeyPoint 0x7f8a1b2c3d90>,
# 关键点2
<KeyPoint 0x7f8a1b2c3da0>,
# 关键点3
<KeyPoint 0x7f8a1b2c3db0>,
# ... 更多关键点
],
'des': {
# 关键点到描述符的映射
<KeyPoint 0x7f8a1b2c3d90>: array([12, 45, 23, ..., 89], dtype=uint8),
<KeyPoint 0x7f8a1b2c3da0>: array([34, 67, 12, ..., 45], dtype=uint8),
<KeyPoint 0x7f8a1b2c3db0>: array([78, 23, 56, ..., 12], dtype=uint8),
# ...
}
},
# 类别1
# ... 直到类别101
}
实际存储的关键点对象示例
keypoint_obj = cv2.KeyPoint(
x=100.5, # x坐标
y=150.2, # y坐标
size=10.3, # 特征点直径
angle=45.6, # 方向角度
response=0.8, # 响应强度
octave=2, # 金字塔层数
class_id=-1 # 所属类别
)
# 对应的描述符
descriptor = np.array([
[12, 0, 45, 23, 89, 34, 67, 12, 45, 78, 23, 56, 89, 34, 12, 67,
23, 89, 45, 12, 78, 34, 56, 89, 23, 45, 67, 12, 34, 78, 56, 89,
12, 45, 23, 67, 89, 34, 12, 56, 78, 23, 45, 89, 67, 34, 12, 56,
23, 78, 45, 89, 12, 34, 67, 56, 23, 78, 45, 89, 12, 34, 67, 56,
23, 78, 45, 89, 12, 34, 67, 56, 23, 78, 45, 89, 12, 34, 67, 56,
23, 78, 45, 89, 12, 34, 67, 56, 23, 78, 45, 89, 12, 34, 67, 56,
23, 78, 45, 89, 12, 34, 67, 56, 23, 78, 45, 89, 12, 34, 67, 56,
23, 78, 45, 89, 12, 34, 67, 56, 23, 78, 45, 89, 12, 34, 67, 56]
], dtype=np.uint8)
# 一个简化后的字典结构
simplified_vec_dict = {
0: {
'kp': [
"关键点1:位置(100,150),大小10.3,角度45.6°",
"关键点2:位置(200,80),大小8.5,角度120.3°",
"关键点3:位置(50,300),大小12.1,角度30.0°"
],
'des': {
"关键点1": "128维向量[12,45,23,...,89]",
"关键点2": "128维向量[34,67,12,...,45]",
"关键点3": "128维向量[78,23,56,...,12]"
}
},
1: {
'kp': [
"关键点1:位置(120,180),大小9.2,角度60.5°",
"关键点2:位置(80,250),大小11.7,角度15.8°"
],
'des': {
"关键点1": "128维向量[23,89,34,...,67]",
"关键点2": "128维向量[45,12,78,...,23]"
}
}
}