又到了做计算机视觉作业的季节。如果你正在被Scene Recognition with Bag of Words这个题目卡住或者只是路过想看看传说中的“视觉词袋模型”到底是怎么一回事这篇文章应该都能帮到你。这个题目几乎是所有计算机视觉课程的经典关卡给你一批不同场景的图片让你用传统的特征工程方式做分类而不是直接甩给神经网络。很多第一次接触的同学会觉得“Bag of Words”这个词很玄幻好像和图像八竿子打不着。但做完你会发现它本质上就是把图片当成一篇“视觉文章”来读——提取局部特征、构建视觉词汇表、统计词频最后训练一个分类器。整个过程逻辑极其清晰代码量也不大是理解传统视觉识别流水线的最佳入口之一。这篇文章我会从整体思路、核心原理、实操代码、参数调优、踩坑记录五个角度把这个作业彻底拆开讲清楚。不管你是刚入门的新手还是想回头补一补传统方法的老手都可以照着下面的流程完整跑一遍并且理解每一步为什么要这么做。1. 场景识别任务的核心思路拆解1.1 词袋模型到底在做什么我们先放下图像想想文本检索里最经典的做法把一篇文章拆成一个个单词统计每个词出现的次数形成一个向量然后用这个向量代表整篇文章。两篇文章如果用的词差不多、频率也接近那它们的内容大概率是相关的。词袋模型Bag of Words迁移到图像上就是这个思路的翻版。图片没有天然存在的“单词”所以我们得自己造视觉单词。做法是先从所有训练图片里提取大量的局部特征比如SIFT特征然后用聚类算法把这些特征分成若干簇每个簇的中心就是一个“视觉单词”。这样一来任意一张图片都可以通过“这张图里出现了哪些特征点、分别属于哪些簇”来统计出一个直方图向量。这个向量就是图片的“词频统计”也就是图片的最终表达。我从一开始就觉得这个方案最巧妙的地方在于它彻底绕开了“全局描述”的难题。单看整张图的颜色直方图、边缘统计根本扛不住光照变化和拍摄角度的干扰。但局部特征加词典映射的方式相当于把图片拆成了大量微小且鲁棒的“视觉碎片”再通过统计分布来还原全局语义。场景识别这个任务靠的恰恰是这种分布信息。1.2 标准流水线的五个环节整个作业的流程非常固定基本就是下面这条链路特征提取对每张训练图片提取SIFT特征关键点 描述子。视觉词典构建把所有图片的SIFT描述子放在一起做聚类得到K个聚类中心这就是视觉词汇表。特征编码对每张图片把它的每个特征描述子映射到最近的视觉单词上统计每个单词出现的次数生成K维的直方图向量。分类器训练把直方图向量作为训练样本送入SVM等分类器进行训练。测试与评估对测试图片执行相同的特征提取和编码流程得到直方图向量送入分类器预测类别计算准确率。我第一次看到这个流程的时候最困惑的是“为什么聚类中心就能当视觉单词”。后来自己想通了SIFT描述子本身是一个128维的浮点向量它刻画了关键点周围的梯度分布。在理想情况下同一类结构比如建筑的角点、树木的纹理附近提取到的SIFT描述子在向量空间里会靠得很近。聚类就是把所有特征描述子按照空间距离分成K堆每一堆的质心就代表了一类“常见局部结构”。所以每一堆中心就是一个“单词”一张图里各堆特征的数目就组成了那张图的“词频”。1.3 为什么这个经典方案仍然值得认真做现在深度学习一统天下很多同学会问这个作业是不是有点过时了我的看法是它虽然不再是主流工程方案但在教学和理解层面有着无可替代的价值。第一它让你彻底理解“手工特征 无监督表达 有监督分类”这套经典范式。你亲手从原始像素走到高级语义表示每一层都是透明可控的出了问题可以精确判断是特征的问题、聚类的问题还是分类器的问题。这种可控性在深度学习里很难做到。第二它对算力几乎零要求。我用一台普通的笔记本电脑处理几百张图片、构建几百维的向量、训练一个线性SVM全程不超过几分钟。这对于初学者来说是一个非常友好的实验环境可以频繁改参数、跑实验、看结果而不是等一块GPU。第三很多工程上的trick特征归一化、降维、网格搜索调参、混淆矩阵分析在这个作业里都会被逼着你用一遍。这些技能不会因为深度学习的普及而失效反而会成为你未来做任何视觉项目时的底层直觉。2. 特征提取与视觉词典构建的实操细节2.1 SIFT特征提取需要关心什么SIFT尺度不变特征变换是这个作业最常用的特征。它有两个输出关键点位置信息和128维的描述子向量。在OpenCV里集成度已经很高了但高版本OpenCV需要先安装扩展模块才能直接调用。在实际作业里用opencv-contrib-python是最省事的方案。在OpenCV中提取SIFT特征核心代码非常简单import cv2 # 创建SIFT提取器 sift cv2.SIFT_create() # 读取图片并转为灰度图 img cv2.imread(road/001.jpg) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 提取关键点和描述子 keypoints, descriptors sift.detectAndCompute(gray, None)这里有几个参数值得注意nfeatures控制最多保留多少个关键点默认是0也就是不限制。我建议做这个作业时限制一下比如设定nfeatures200左右。这样做不是为了“降低精度”而是为了保证每张图提取到的特征数量大致均衡避免某些纹理密集的图片产生几万个特征而平滑的图片只有几十个。特征数量差异过大会影响后续聚类的统计特性也会让直方图向量出现明显的偏置。contrastThreshold和edgeThreshold也很关键。前者过滤掉对比度太低也就是平坦区域的特征后者过滤掉边缘响应过强的特征。默认值在多数场景下是合理的但如果你发现提取到的关键点明显集中在少数纹理区域或者整体数量过少可以适当调低contrastThreshold比如从0.04调到0.03让更多弱纹理区域也能贡献特征点。一个重要提示务必保证所有图片先转成灰度图再提取特征。有些同学直接对BGR三通道图调用detectAndCompute虽然OpenCV不报错但结果只在第一通道上提取容易出现信息缺失和结果不一致的问题。2.2 视觉词典的规模怎么选视觉词典的大小就是聚类中心数量K一般取50到200之间。它本质上是一个“表达分辨率”的旋钮K太小不同结构的特征被强行归成一类直方图区分度不够K太大每个类别的统计样本变少直方图对噪声敏感而且训练和编码的计算量都会明显上升。我是用二分法确定K值的先设100跑一遍看准确率然后试50和200对比结果。经过几轮实验我自己的经验是K在150附近效果最好。不过这个值非常依赖数据集内容不存在一个放之四海而皆准的K。校园场景数据集类别多、纹理杂K可能要大一些如果只是几个大类之间的简单区分K甚至可以小到50。聚类这一步我强烈建议用MiniBatchKMeans而不是普通KMeans。当特征描述子数量达到几十万甚至上百万时经典K-means的每次迭代都要计算所有样本到所有质心的距离内存和时间开销都很吓人。MiniBatch版本每次随机抽一小批样本更新质心运行速度快一个数量级。我在实践中用MiniBatch跑30万特征只需要十几秒而普通KMeans可能要好几分钟。from sklearn.cluster import MiniBatchKMeans import numpy as np # all_descriptors: 所有训练图片的描述子堆叠成的大矩阵形状为 (N, 128) K 150 kmeans MiniBatchKMeans(n_clustersK, batch_size1000, random_state42) kmeans.fit(all_descriptors) # 训练结束后质心矩阵是 (K, 128) visual_words kmeans.cluster_centers_聚类之前要先把所有描述子堆叠起来。这看起来简单但有一个很现实的内存问题如果训练图片数量很多所有描述子矩阵可能会占用几个GB内存。我的做法是边提取边存盘把每张图的描述子追加到磁盘上的.npy文件里全部提取完后再一次性加载做聚类。这听起来多此一举但它真的能避免程序跑到一半内存崩溃的窘境。2.3 空间金字塔匹配要不要用很多作业的评分标准里会有一项“进阶加分项”在词袋基础上加空间金字塔匹配SPM。它的思想也不复杂直方图只统计全局词频丢失了空间位置信息。那么就把图像划分成若干层级的网格1×1、2×2、4×4在每个网格内各自计算直方图最后把不同层级的直方图拼接起来得到一个带空间信息的更长向量。这个改进在场景识别任务里非常有效因为场景的语义往往和空间布局强相关。天空大多在图像上方路面多在下方建筑和树木的位置也有一定规律。如果只用全局直方图一张“森林”和一张“城市高层建筑”可能在局部特征分布上存在混淆但把空间位置加进来后高层建筑的特征集中在图像中部森林的特征分布更随机这种差异就很容易被分类器捕捉到。实现SPM的关键是拼接层级特征时做权重分配。标准做法是第L层网格数是2^L的直方图权重设为 2^(−L)也就是说更细粒度的网格给更高权重。比如三层金字塔总向量长度 K × (1 4 16) 21K。如果K100那么最终向量就是2100维线性SVM处理起来依然很轻松。我建议先把普通词袋跑通确认准确率基准再尝试SPM。如果直接一上来就做SPM排查问题的时候会多一个变量反而容易绕弯子。3. 特征编码与分类器训练的关键环节3.1 直方图编码的三种实现角度拿到视觉词典之后下一步就是把每张图片“翻译”成一个K维直方图向量。这一步的实现思路其实有好几个层次我按从低到高排一下方便你对号入座。最直接的做法遍历图片的每一个描述子计算它到K个聚类中心的距离取最近的聚类中心下标然后对应位置计数。这种方式最直观但速度很慢。一张图如果有几百个描述子每个描述子要算150个距离总共就是几万次计算。如果图片数量多跑起来会让人着急。第二个层次用scipy.spatial.distance.cdist一次性计算所有描述子到所有质心的距离矩阵然后取每行的最小值下标。这个方法在批量处理时已经很快了500个描述子对150个质心也就是一瞬间的事。实现起来也不算复杂from scipy.spatial.distance import cdist def build_histogram(descriptors, visual_words): if descriptors is None or len(descriptors) 0: return np.zeros(len(visual_words), dtypenp.float32) dists cdist(descriptors, visual_words, metriceuclidean) nearest np.argmin(dists, axis1) hist np.bincount(nearest, minlengthlen(visual_words)) return hist.astype(np.float32)第三个层次是工程化的加速方式也是我最终采用的方案。直接用kmeans.predict(descriptors)得到最近质心的下标因为聚类模型本身已经保存了质心predict操作内部做了距离计算函数签名简洁将来做批量并行也更方便。但需要注意sklearn的predict对每张图的多次调用会有一点函数开销如果你的图片数量特别大用cdist做矩阵运算拍平反而更快。无论是哪种编码方式编码完成后建议做一次L2归一化。原因很简单直方图向量的L2范数和图片特征总数直接相关。纹理丰富的图片特征多计数就大向量范数也大纹理稀疏的图片计数小范数也小。SVM这类分类器对特征尺度的差异比较敏感如果不归一化很容易把“特征数量多”误判成“类别属性不同”。归一化之后每个向量都约束在单位球面上分类器学到的权重才更有意义。def l2_normalize(vec): norm np.linalg.norm(vec) if norm 0: return vec / norm return vec3.2 从直方图到分类结果SVM的选择与训练编码完成后每张训练图片就对应一个K维向量加上它的类别标签这就是一个标准的监督学习问题。这个作业里最常用的分类器是SVM原因也很直接训练样本数量通常不大维度中等线性核或RBF核都便宜效果好解释性强。在sklearn中实现非常轻量from sklearn.svm import SVC from sklearn.metrics import classification_report, confusion_matrix clf SVC(kernellinear, C1.0) clf.fit(X_train, y_train) y_pred clf.predict(X_test)我第一次跑的时候直接上了RBF核结果发现准确率并没有比线性核高多少训练时间倒是翻了好几倍。后来仔细想想我们的特征向量经过了L2归一化维度也就几百到几千这种低维归一化数据用线性核往往就已经足够。RBF核的优势在于捕捉非线性边界但前提是你有足够的样本去支持这种复杂模型。如果训练集每类只有几十张图强行用RBF很容易过拟合在测试集上泛化能力反而差。对于SVM的参数C我习惯在[0.1, 1, 10, 100]这个范围内做网格搜索。C越小对误分类的惩罚越轻决策边界越平滑C越大模型越倾向把所有训练样本都分类正确边界越复杂。如果发现验证集准确率随着C增大而升高说明模型还没到过拟合区可以继续往上调如果C增大后训练准确率接近100%但测试准确率反而下降那就是过拟合了需要往回调。分类器训练完之后一定不要只看一个总体准确率。场景识别作业最喜欢考察的往往就是各类别的具体表现。用混淆矩阵confusion matrix一眼就能看出哪些类之间容易被混淆。比如某次我跑的模型森林和山脉两类经常互相误判原因在于这两类的SIFT特征分布确实很接近。这时可以考虑针对性调整特征权重、增加更多训练样本或者直接在金字塔层级上增强空间信息。训练集和测试集的划分方式也必须认真对待。有些数据集同一场景的图片可能来自连续采集的序列直接随机划分会让相邻帧同时出现在训练集和测试集里导致评估结果虚高。稳妥的做法是先按场景编号分组确保同一采集序列的图片不会被拆到两边。这一点在作业里容易被忽略但实际工程里非常关键。3.3 评估指标怎么读除了总体准确率建议多算几个指标每类准确率、平均类准确率mean per-class accuracy、混淆矩阵。总体准确率可能被样本多的类别带偏平均类准确率更能反映模型在所有类别上的均衡表现。举个例子如果数据集中“森林”类有100张图片“卧室”类只有5张模型只要把森林都猜对就能拿到很高的总体准确率哪怕卧室全军覆没。平均类准确率会把这个水分挤掉。我跑作业时有一个很痛苦的经历总体准确率看起来接近80%但看平均类准确率却只有65%就是因为我忽略了类别不均衡。后来我重新按类别平均分配训练样本或者对样本少的类别做数据增强旋转、翻转、亮度调整平均类准确率才明显改善。另外尝试输出一下模型在测试集上的decision_function或者predict_proba的分布能帮你判断模型到底是在“犹豫不决”还是“自信满满”。SVM的decision function是一个有向距离如果大量样本的decision value集中在0附近说明模型对边界附近的样本信心普遍不足。这种情况下可以适当增加特征维度比如增大K或者启用SPM让特征表达更丰富通常能拉开边界距离。4. 从零到一完整流程代码框架与踩坑复盘4.1 主干流程代码速览把前面所有环节串起来一份可以直接跑通整个作业流程的代码骨架大概长这样。我按步骤分了几个函数这样每一步出问题都好定位。import os import cv2 import numpy as np from sklearn.cluster import MiniBatchKMeans from sklearn.svm import SVC from sklearn.metrics import accuracy_score from scipy.spatial.distance import cdist # 工具函数读取图片并提取SIFT描述子 def extract_sift_descriptors(img_path, max_features200): img cv2.imread(img_path) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) sift cv2.SIFT_create(nfeaturesmax_features) _, descriptors sift.detectAndCompute(gray, None) return descriptors # 步骤一提取所有训练图片的SIFT描述子 def collect_all_descriptors(train_paths): all_descs [] for path in train_paths: descs extract_sift_descriptors(path) if descs is not None: all_descs.append(descs) return np.vstack(all_descs) # 步骤二构建视觉词典 def build_visual_vocabulary(all_descs, K150): kmeans MiniBatchKMeans(n_clustersK, batch_size1000, random_state42) kmeans.fit(all_descs) return kmeans # 步骤三图片编码为直方图 def encode_image(img_path, kmeans, K): descs extract_sift_descriptors(img_path) if descs is None: return np.zeros(K, dtypenp.float32) dists cdist(descs, kmeans.cluster_centers_, metriceuclidean) nearest np.argmin(dists, axis1) hist np.bincount(nearest, minlengthK).astype(np.float32) norm np.linalg.norm(hist) if norm 0: hist / norm return hist这段代码有一个细节extract_sift_descriptors里我在每次调用时都重新创建cv2.SIFT_create()。如果你把SIFT实例作为全局对象创建一次然后反复调用detectAndCompute性能会更好。我在上面的写法故意保持函数独立性是为了让你先跑通逻辑后续再优化性能。4.2 搭建训练和测试矩阵的环节图片路径和标签的管理也很容易出问题。我的习惯是先用一个简单的脚本把所有图片路径扫描到一个列表里同时记录每个路径对应的类别标签。为了不让自己在后续实验里把路径搞乱我会把类别名映射成整数标签存在一个字典里同时把映射关系保存成JSON文件方便随时查阅。import json from collections import defaultdict def scan_dataset(root_dir): class_to_label {} image_paths [] labels [] for class_name in sorted(os.listdir(root_dir)): class_dir os.path.join(root_dir, class_name) if not os.path.isdir(class_dir): continue if class_name not in class_to_label: class_to_label[class_name] len(class_to_label) for img_name in os.listdir(class_dir): if img_name.lower().endswith((.jpg, .jpeg, .png)): image_paths.append(os.path.join(class_dir, img_name)) labels.append(class_to_label[class_name]) with open(class_to_label.json, w) as f: json.dump(class_to_label, f) return image_paths, np.array(labels)数据集本身的质量也值得花时间检查。我遇到过一个非常隐蔽的问题部分图片实际上已经损坏cv2.imread返回的是None。如果你不检查返回值SIFT提取会直接报空指针异常或者静默失败最后导致训练样本数量和标签数量对不上。所以数据处理阶段一定要有防御性检查对每张图确认读取成功后再进特征提取流程。4.3 我亲自踩过的坑一份问题速查表做这个作业过程中我记录了不少问题这里整理成表格方便后面做作业的同学直接对照排查。常见问题表现原因解决办法SIFT提取不到特征点描述子全为None图片太模糊或对比度太低调低contrastThreshold对图像做直方图均衡化聚类时内存爆炸程序卡死或强制退出所有描述子一次性堆叠占用过大边提取边存盘用MiniBatchKMeans限制每张图特征数直方图向量全是0某些图片编码长度为0图片没有提取到任何特征编码前检查描述子是否为空为空则返回全零向量训练准确率高但测试准确率低明显过拟合K值过大或SVM的C过大减小K降低C增加训练样本使用交叉验证不同跑次结果差异大准确率波动明显随机划分数据集或KMeans随机初始化固定random_state按场景分组划分数据编码后直方图范数为0归一化报错图片完全没有特征点对范数为0的向量做保护直接返回全零类别混淆明显某些类互相误判特征表达缺乏空间信息尝试SPM增加K值对易混淆类单独分析这里面最让我印象深刻的是分类准确率波动的问题。有一阵子我每次运行代码结果都不一样有时80%有时73%。排查了很久才意识到问题出在训练/测试集划分上——每次运行都用train_test_split随机划分样本分布一变结果自然跟着变。后来我把随机种子固定并且改用按场景分组的方式划分数据实验结果才稳定下来。从这里我学到最重要的一课做实验要想可复现所有随机过程都必须固定随机种子数据划分要预先存储一份清单。4.4 工程效率提升的几个小技巧如果你只是想把作业跑完上面这些已经足够了。但如果你想让程序跑得更快、代码更好维护下面这几个优化点可以参考。第一批量处理图片时可以用并行。Python里concurrent.futures.ThreadPoolExecutor就能简单实现多线程提取特征。SIFT提取本身是CPU密集型GIL的影响有一点但实测下来多线程仍然能带来接近线性的加速因为OpenCV底层的C/C实现大多会释放GIL。第二用缓存机制避免重复提取特征。特征提取是整个流程中最耗时的一环。我会把每张图片的SIFT描述子保存为.npy文件下次跑实验时直接加载省去重复计算。做网格搜索调参的时候这个缓存能帮你节省大量时间。第三向量的存储尽量用float32而不是float64。128维SIFT描述子本身是float32聚类中心也是float32。如果你在中间步骤不小心转成float64内存占用直接翻倍。计算直方图的时候用np.bincount输出是int64需要手动转会float32别偷懒跳过这一步。5. 进阶方向与最后的个人体会这个作业已经做完的朋友如果想再往深走一步除了前面提到的SPM还可以尝试局部特征聚合描述符VLAD或者费舍尔向量Fisher Vector。这两种编码方式在传统方法时代属于“王炸级”手段本质上都是在保留更多统计信息VLAD记录的是特征点到聚类中心的差值累积Fisher Vector则用高斯混合模型建模特征分布。它们都能在直方图基础上获得更强大的表达能力但代码复杂度也会上一个台阶。另外可以试着把词袋向量和深度学习特征比如卷积网络中间层输出做一次特征拼接再扔给SVM。这种“传统 深度”的混合思路在实际比赛里经常出现效果往往优于单独使用任何一种。最后说一点我自己的感触。做完这个作业最大的收获不是会了SIFT、KMeans和SVM这几个工具的调用而是建立起了一种“问题拆解”的思维习惯面对一个视觉任务先想清楚这个任务需要什么样的图像表达然后反推开发现层、编码层、分类层分别要做什么。词袋模型虽然古老但它把这种思维训练得特别扎实。也正因为如此后来再接触深度学习我理解卷积网络的感受野、特征图语义、全连接层分类这些概念时会觉得特别顺——因为词袋流水线里的每一层在深度学习里几乎都能找到对应的影子。如果你正在被这个作业折磨别急着抄代码。花一个小时把上面的原理部分吃透再自己动手写一遍流程。相信我当你看到自己的模型把一张张图像正确分类那种“无中生有”的成就感是直接跑通一个预训练模型完全体会不到的。