做图像检索或者图像分类时,大家最先想到的往往是把整张图塞进神经网络,但当你手里的数据量不大、硬件资源也有限时,传统特征加视觉词袋(Bag of Visual Words)这套方案反而特别能打。这篇记录一下我用Python实现SFIT特征抽取,再通过K-means聚出visual words的完整过程。开头先说清楚,这里的SFIT其实就是业界常说的SIFT(Scale-Invariant Feature Transform),很多项目文档里手误写成SFIT,两者指向同一个特征提取算法。整篇文章不光是贴代码,还会把每一步的设计思路、参数选择、踩坑经验一起写了,适合刚接触视觉词袋的小白,也适合那种特征能提出来但不知道怎么组织使用的进阶读者。
1. 先把SFIT、K-means和visual words这几个概念砸实
1.1 SFIT特征到底在提取什么
SFIT特征的核心是“局部且鲁棒”。它不会像CNN那样把整幅图卷成一个全局向量,而是在图像里自动找到一批关键点,比如角点、边缘交汇点、纹理突变点,然后在每个关键点周围计算一个128维的描述子。这个描述子对旋转、尺度缩放、光照变化都有很强的容忍度,这就让它在图像检索场景里特别吃香。
实现上,标准的SIFT流程包括尺度空间极值检测、关键点精确定位、方向分配、描述子生成。尺度空间就是把原图不断降采样并做高斯模糊,形成一组金字塔,然后在金字塔里找DoG(差分高斯)的极值点。这个地方有个细节,很多初学者不理解为什么要建金字塔,简单说就是为了让特征具有尺度不变性——物体离镜头远,尺度就小;离镜头近,尺度就大。通过多层金字塔,算法能在不同尺度下都能找到同一个物理点对应到像素上的位置,描述子最后再根据关键点邻域梯度方向直方图来生成,所以旋转不变性也顺带解决了。
我当时用的是OpenCV里的SIFT实现,之所以没自己从零写,是因为这个算法本身的分步实现很容易出现数值问题,比如尺度空间参数如何选、插值怎么收敛,调起来非常烦琐。OpenCV的版本经过了大规模工程验证,性能稳定。后面我提供的方法同样适用于任何能输出“关键点+描述子”的特征库,替换起来并不难。
1.2 visual words到底解决了什么问题
一张普通的图片经过SIFT提取后,通常会得到几千甚至上万个128维描述子。假设有1000张图,每张图平均3000个特征,那就是300万个高维向量。如果做图像匹配的时候把这些向量全部两两比较,计算量会直接爆掉,检索响应速度根本没法看。
visual words(视觉词袋)借鉴了文本检索里“词袋模型”的思路。文本处理是把一篇文档拆成单词,然后统计每个单词出现的次数,形成词频向量;图片处理就是把SIFT描述子当成“视觉词汇”,但问题是每个描述子都是连续的浮点向量,不像文本单词那样天然离散。这时候K-means就派上用场了:把大量描述子聚类成K个簇,每个簇的中心点就代表一个“视觉单词”,所有描述子被归到离它最近的中心点,也就是被“量化”成某个单词。最后统计每个单词在图中出现的次数,用这个K维直方图来表达整张图。
这个K维向量的维度一般在500到2000之间,相比原来百万级特征向量,计算和存储成本大幅下降,而且下游无论是做SVM分类、相似度检索还是聚类,都是非常成熟的操作。说白了,visual words是给图像特征做了一次“有损压缩”,用一点点量化误差换来了数量级上的计算效率提升。
1.3 K-means在整条链路中扮演的角色
K-means是整个视觉词袋的心脏。它的目标是把N个描述子划分到K个簇中,使得每个点到所属簇中心的距离平方和最小。这里有个现实的痛点是K-means对初始中心非常敏感,不同初始化可能收敛到不同的局部最优解。因此实际工程里,我强烈建议用k-means++初始化策略,它会让初始中心尽量彼此远离,显著提升聚类质量。
还有一个容易被忽略的问题,就是距离度量。SIFT描述子本质上是梯度方向直方图拼接出来的向量,使用欧几里得距离是SIFT匹配里最常用、也最稳妥的方案,很多论文和开源项目都验证过这种组合的稳定性。如果把数据做了归一化,本质上就是在比较角度而不是幅值,效果会更好——这个在后面的预处理章节里详细展开。
另外一个重要参数是聚类轮数。K-means的每一次迭代包含“分配”和“更新”两步,迭代轮数太少会不收敛,太多又浪费时间。sklearn里的K-means默认参数其实已经比较可靠了,但如果你操作的数据量特别大,我建议切到MiniBatchKMeans,它每次只取一小批样本更新中心,速度优势明显,后面的章节会用实际数据说明。
2. 动手前的技术选型与参数设计
2.1 工具链怎么搭配最省心
实现这套方案,我用的组合是Python + OpenCV + NumPy + scikit-learn。这几个库的定位非常清晰,OpenCV负责SIFT特征提取,NumPy负责高维矩阵操作,scikit-learn负责K-means聚类和后续评估。它们各自都有成熟的开源实现,能覆盖从特征到模型的完整链路。
有人可能想用VLFeat,那个库在特征提取上确实以高质量著称,但它是C语言库,Python调用要自己维护接口,工程成本高。也有人想用PyTorch或者TensorFlow里的卷积特征替代SIFT,这在深度特征检索里确实是趋势,但视觉词袋的核心思想本身不依赖特定特征来源,你先用SIFT跑通整套流程,之后换成深度特征也只是替换特征抽取部分的代码,这种设计更利于调试和排错。
版本问题必须单独拎出来说。OpenCV从4.x开始,SIFT算法被移到了contrib模块里,如果你用的是pip默认安装的opencv-python,直接调用cv2.xfeatures2d.SIFT_create()可能报错。解决办法是安装opencv-contrib-python版本,调用路径建议写成cv2.SIFT_create(),部分旧版本里用cv2.xfeatures2d.SIFT_create()。为了避免这种混淆,建议代码里对不同版本做兼容判断,后面示例代码里我会写清楚。
2.2 K值怎么定:肘部法加轮廓系数
K值可以说是视觉词袋里最难拍板的超参数。太小了,不同的局部结构被强行归成一类,视觉单词区分度不够;太大了,直方图维度高,稀疏严重,而且聚类中心之间互相重叠,模型泛化能力反而下降。
我在项目中确定K值的方法分两步走。先做肘部法:对一组候选K值(比如200、400、600、800、1000),分别用K-means聚类,记录每个K对应的簇内误差平方和(SSE),然后画曲线找“肘部”位置。注意特征量大的时候,每跑一次K-means都要几十分钟,这时候可以抽一部分特征子集来跑预实验,不用把全部数据压进去。
肘部法代码示例很简单:
import numpy as np from sklearn.cluster import MiniBatchKMeans def search_k(features, k_list): sse = [] for k in k_list: km = MiniBatchKMeans(n_clusters=k, batch_size=10000, random_state=42) km.fit(features) sse.append(km.inertia_) return sse # features 是所有描述子堆叠后的矩阵,形状为 (N, 128)但肘部法给出的K只是一个粗略区间,接下来我会结合轮廓系数(Silhouette Score)做最终确认。轮廓系数衡量样本与自身簇内相似度和与最近簇相似度的差异,取值范围在-1到1之间,越接近1说明聚类越紧凑、簇间分离越明显。不过当N特别大时,轮廓系数的计算也很耗时,可以抽样1万到5万个特征点做评估就够了。
我实际项目里遇到的情况是这样的:数据集有3000多张图,描述子总量接近800万,肘部点在K=800附近,轮廓系数在K=600附近最高,最后取了K=800。这其实说明纯靠一次实验定K并不可靠,最好把K当作一个待搜索的超参数,放到后续分类或检索效果评估里去调,这样比单看聚类指标更符合实际需求。
2.3 描述子的预处理、内存估算与归一化
SIFT描述子默认是uint8类型,值域0到255。直接把uint8喂给K-means也能跑,但我建议转成float32并做L2归一化,让每个描述子的模长为1。为什么要这么做?因为欧式距离对向量的绝对大小敏感,如果两个描述子内容相似,但一个总体梯度强度大,一个梯度强度小,直接计算距离时数值就会偏大,影响聚类归属。归一化之后,比较的是方向分布而非幅值,这对光照变化更鲁棒。
内存占用这块很多人会翻车。假设有1000张图,每张图提取2000个特征,描述子总数就是200万,每个float32占用4字节,那么128维的矩阵占用空间大约是2000000乘以128乘以4字节,算下来正好1GB。如果扩展到3000张图,就是3GB起步。如果不做任何处理,直接一次性把所有描述子读进内存,很快就把设备搞炸。
处理大数据的正确姿势有两种。第一种是分批抽取,每处理完一批图片就把描述子追加到磁盘上的二进制文件里,最后再统一加载;第二种是直接在抽特征的同时做一个随机采样,每张图只保留固定数量(比如300个)的描述子。后面那条路在聚类环节效果并没有差太多,而且非常管用。我自己的习惯是先用一个采样比例控制总特征量在100万以下,这样既保证聚类中心稳定,又不会让训练过程拖太久。
3. 完整实现流程:从图片目录到视觉词袋向量
3.1 数据准备与SFIT特征批量抽取
以我实际做过的某图像检索Demo为例,数据集是一个文件夹下若干子目录,子目录名对应不同类别。写代码的第一步就是把所有图片路径收集起来,然后统一做预处理。预处理里面最关键的一个决定是图像是否缩放。SIFT最怕的就是输入图像过大产生大量低质量关键点,建议把长边限制在800像素以内,过小的图像则适当放大到300像素左右,避免关键点太少。
抽取特征的代码实现如下:
import cv2 import numpy as np import os def extract_sift_features(image_path, max_dim=800): img = cv2.imread(image_path) if img is None: return [], None h, w = img.shape[:2] scale = min(1.0, max_dim / max(h, w)) if scale < 1.0: img = cv2.resize(img, (int(w * scale), int(h * scale))) gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 兼容不同OpenCV版本的SIFTF创建方式 try: sift = cv2.SIFT_create() except AttributeError: sift = cv2.xfeatures2d.SIFT_create() keypoints, descriptors = sift.detectAndCompute(gray, None) return keypoints, descriptors这里有三个值得抠的细节。第一,统一转灰度图,SIFT定义的梯度场本身是在单通道上计算的。第二,detectAndCompute一次完成检测和描述,不要先detect后compute两步走,那样整体慢。第三,关键点对象里包含坐标、尺度、方向、响应值等信息,但我们只需要它的描述子。如果你发现某些图关键点数量异常少,可以调SIFT构造参数,比如nfeatures、contrastThreshold,后续章节会展开讲。
批量抽取时我习惯维护两个列表:一个是所有图片的路径列表,一个是每张图对应的描述子列表。描述子不会直接全部合起来,因为后续构建直方图还要按图片分开统计,预先保留结构能少走很多弯路。
3.2 用MiniBatchKMeans训练视觉词典
拿到全部描述子后,先做一个总矩阵堆叠。如果总量巨大,可以提前采样一部分,我这里为了演示假设总量是可控的。然后直接上MiniBatchKMeans,原因是标准K-means在百万级别的样本上迭代非常慢,MiniBatch用随机子批更新中心,速度能快几倍甚至一个数量级,聚类质量在视觉词袋场景下几乎没差别。
from sklearn.cluster import MiniBatchKMeans all_descriptors = np.vstack(desc_list).astype(np.float32) # 归一化,单位长度 all_descriptors /= (np.linalg.norm(all_descriptors, axis=1, keepdims=True) + 1e-8) K = 800 km = MiniBatchKMeans( n_clusters=K, batch_size=8192, n_init=5, max_iter=300, random_state=42, reassignment_ratio=0.01 ) km.fit(all_descriptors) np.save("visual_words.npy", km.cluster_centers_)有几个参数必须说明。batch_size控制每批样本量,太小会让聚类中心抖动,太大则失去MiniBatch的速度优势。n_init=5表示做5次不同初始化然后保留最优解,虽然耗时增加,但对最终词典稳定性有很大提升。reassignment_ratio是MiniBatchKMeans特有的参数,它决定每次迭代重分配的比例,0.01是一个比较保守的数值,能防止某些簇缩小到不可用。训练结束后把cluster_centers_存成npy文件,这就是以后可以直接加载的视觉词典。
3.3 从词典到每张图片的直方图向量
词典训练完,所有图里每一个SIFT描述子都能在词典里找到最近的视觉单词。统计这些单词的出现频率,得到的就是这张图的视觉词袋表示。注意,这里有个容易翻车的点,就是词典训练的时候做了归一化,所以预测阶段的数据也必须做同样的归一化,否则近邻判断会失真。
def build_bow_vector(descriptors, km_model, K): if descriptors is None or len(descriptors) == 0: return np.zeros(K, dtype=np.float32) desc = descriptors.astype(np.float32) desc /= (np.linalg.norm(desc, axis=1, keepdims=True) + 1e-8) labels = km_model.predict(desc) hist = np.bincount(labels, minlength=K).astype(np.float32) hist /= (np.sum(hist) + 1e-8) # 归一化成概率分布 return hist bow_matrix = [] for desc in desc_list: bow_matrix.append(build_bow_vector(desc, km, K)) bow_matrix = np.vstack(bow_matrix)为什么不直接用词频而要归一化?因为图片中包含的关键点数量差异很大,一张纹理丰富的图可能有5000个特征,纹理平滑的图可能只有500个特征,如果不归一化,词袋向量的绝对数值完全被特征数量主导,下游分类器会把注意力放在数量差异上而不是内容差异上。
到这里,每张图就变成了一个K=800的向量,比原始描述子数量少了好几个数量级。你可以把这个bow_matrix保存成cvs或者npz格式,后面无论是做SVM、KNN还是相似度检索,都是直接处理这个矩阵即可。
3.4 接一个最简下游任务:用向量做图像分类
为了验证词典质量,我用一个简单的线性SVM做了分类实验。数据分成训练集和测试集,划分时要注意按目录类别分层,避免某一类的全部样本都进了测试集。代码大约是这个样子:
from sklearn.svm import LinearSVC from sklearn.model_selection import train_test_split from sklearn.metrics import accuracy_score X_train, X_test, y_train, y_test = train_test_split( bow_matrix, labels, test_size=0.2, stratify=labels, random_state=42 ) clf = LinearSVC(C=1.0, max_iter=5000, random_state=42) clf.fit(X_train, y_train) pred = clf.predict(X_test) print("accuracy:", accuracy_score(y_test, pred))这个实验里我得到的准确率大概在0.84左右,对一个小规模Demo来说已经够用。还有一个细节,线性SVM需要在数值上归一化特征,我上面已经做了直方图归一化,所以这个处理的隐含条件已经满足。如果你发现收敛前出现警告,可以调高max_iter,或者先对向量做标准化,问题基本马上消失。
4. 我在实际项目里踩过的坑
4.1 SIFT关键点太少或太密怎么处理
关键点太少通常是图像太暗、太模糊,或者输入尺寸太小。这里有个调参经验:SIFT的contrastThreshold默认是0.04,如果关键点太少,把它降到0.01到0.02,能显著多出不少特征点;如果关键点太多、耗时太长,就反向调高到0.06左右。edgeThreshold控制边缘响应的过滤,默认10,如果特征点聚集在边缘结构上,可以把它调大一点。
关键点太密带来的问题是描述子数量爆炸,内存和训练时间都会翻倍。我的经验是每张图限制最多1000到1500个特征点,具体通过SIFT_create(nfeatures=1200)来控制。注意nfeatures参数并不是严格上限,它是在默认提取结果中按响应值降序保留前N个,但能用就行,大多数场景足够稳定。
4.2 OpenCV版本差异导致的SIFT不可用
这个问题我前面提过,几乎每个用OpenCV 4.x的人都会被坑一次。如果你在import之后调用cv2.SIFT_create()时报AttributeError,说明当前安装的是不带contrib模块的opencv-python。解决方案有两个:卸载后安装opencv-contrib-python,或者在代码里做一次兼容处理。我自己的项目里直接用的try/except方式,旧版本走xfeatures2d路径,新版本走SIFT_create路径,这样跨机器迁移代码时完全不用改。
还有一个版本差异是特征点绘制函数,cv2.drawKeypoints在不同版本里的签名不一致,但那只是可视化,对核心流程没有影响,不必过度纠结。
4.3 K-means聚类结果不稳定且词典语义错位
这是视觉词袋里最隐蔽的一个坑。如果不设置random_state,每次运行的K-means结果都会不一样,聚类中心编号的顺序完全随机。这就导致一个问题:模型训练阶段用的词典和测试阶段用的词典如果不一致,同一个视觉单词在不同词典里的语义千差万别,直方图向量根本没法对齐。
解决方案其实很简单。第一,训练时固定random_state;第二,把聚类中心保存下来,之后所有流程都复用同一个模型文件,不要重新训练。记住这句话:视觉词典一旦生产完毕,在后续处理中就是不可变对象。如果之后又新增了一批图片,你可以直接用旧词典做量化,不需要重训;除非数据分布变化太大,才考虑增量更新或全部重训。
另外,MiniBatchKMeans因为随机采样的原因,即使固定random_state,在不同机器上结果也可能出现细微差异。对学术实验来说无伤大雅,但对生产环境来说,建议把完成训练后的模型直接序列化保存成joblib或者pickle文件,彻底取代每次在线训练。
4.4 内存爆炸和训练时间过长
这个坑我在刚开始跑全量数据时踩得很深。当时直接对所有图像的描述子做了vstack,结果一个9000多张图的库直接导致16GB内存耗尽。后面我改成边扫描边保存,把特征分批写到磁盘,再用生成器分批读取训练,问题才彻底解决。具体策略是我在3.1里提到的,每张图最多保留800个描述子,总特征量控制在百万级别以内。如果数据量再大一个数量级,你就要考虑用局部敏感哈希这类近似近邻方法替代K-means了,因为K-means的复杂度基本是O(N·K·d·iter),N和K都不可能无限涨。
训练时间上还有一个小技巧:先用少量特征粗聚类,取聚类中心作为下一轮K-means的init参数,这样能省掉不少迭代轮次。MiniBatchKMeans的init参数可以直接传入初始中心点矩阵,实际测试能省20%到40%的时间。
4.5 直方图过于稀疏和量化误差
视觉词袋有个天然缺点,量化误差。当K取到800甚至1000时,每张图的局部特征被硬分配到单一单词,边界附近的特征很容易被分到错误的簇。缓解方法之一是使用软分配(soft assignment),也就是不只记录最近邻单词,而是保留距离最近的3到5个单词,并按距离加权累加到直方图里。代码改动不大,但检索和分类效果通常有明显提升。另一方面,直方图稀疏会导致下游SVM过拟合,可以用L2正则化参数调小C,或者对向量做平滑处理,都能缓解。
5. 进阶优化的几个方向
5.1 从词袋到VLAD和Fisher Vector
视觉词袋丢失了大量信息,因为它只记录单词出现次数,不记录每个特征偏离中心的方向和程度。VLAD(Vector of Locally Aggregated Descriptors)在这个基础上做改进:对每个视觉单词,把所有归属于它的描述子与中心点的残差向量累加,最终向量的维度是K乘以128,信息量比词袋高得多。Fisher Vector更进一步,用高斯混合模型建模特征分布,同时保留均值和方差信息,精度往往更高,但计算量和特征维度也随之暴涨。如果你的数据规模不大,想追求更高精度,这两条路都值得尝试。如果对速度和存储要求高,还是词袋加软分配更划算。
5.2 深度特征与视觉词袋的混合方案
现在很多项目会把SIFT换成深度卷积特征,比如从预训练网络中提取某一层的特征图,然后对这些特征图再做聚类或编码。这套方法的好处是特征语义更丰富,缺点是小数据集下深度学习特征容易过拟合,而且特征维度通常更大。比较实用的折中方案是:用传统SIFT特征先搭建一套baseline,再用深度特征替换特征抽取模块,其余词袋、分类、检索链路完全复用。这样既能对比不同特征的效果,又不会推倒重来。
混合方案里还有一个更好的使用方式是做检索重排序——先用视觉词袋快速召回一批候选图,再用描述子级别的几何一致性校验做精确排序,准确率和速度都能兼顾。这一点在真实商业系统里很常见,Rank的第一步必须快,精度靠后面精排补。
在我自己的实践里,视觉词袋这套方法给人的感觉就是“够用且可控”。它不需要GPU,不需要海量数据预训练,每一个模块都能单独调试,出问题也能精确定位到底是特征的问题还是聚类的问题。比起端到端的深度模型,它更像一把用顺手的工具刀,在数据量几百到几万的规模里非常合适。最后再分享一个小技巧:如果你要拿这套流程做实验对比,务必把特征抽取参数、K值、聚类初始化、归一化方式全部固化成一份配置,这样才能保证实验可复现,不然一个K值没锁死,前面的努力就白费了。