07-视觉项目落地:物体特征库存入与相似物体检索

发布时间:2026/8/21 1:41:28
07-视觉项目落地:物体特征库存入与相似物体检索 视觉项目落地物体特征库存入与相似物体检索黑漂技术佬的第7篇实战笔记 —— 从拍一张照片到认出是什么商品中间到底发生了什么一、完整视觉检索系统架构先不写代码把整体流程捋清楚。一个视觉检索系统分两个阶段离线阶段提前做好存到库里商品实物 → 多角度拍照 → 特征提取模型 → 512维向量 → 存入向量数据库 ↓ 关联元数据(商品ID/名称/价格)在线阶段用户操作时实时运行摄像头拍照 → YOLO检测物体区域 → 裁剪目标 → 特征提取模型 → 512维向量 ↓ 向量数据库检索TopK → 返回最相似的商品信息说白了离线阶段是建商品目录在线阶段是看图找商品。两阶段用同一个特征提取模型保证向量在同一空间里可比。二、特征库构建2.1 商品多角度拍摄一个商品不能只拍一张照片。可乐瓶正面和侧面看起来差别很大特征向量也差得远。实际操作中每个商品至少拍6-8个角度# 商品图片目录结构 product_images/ ├── SKU001_可乐/ │ ├── front.jpg # 正面 │ ├── back.jpg # 背面 │ ├── left.jpg # 左侧 │ ├── right.jpg # 右侧 │ ├── top.jpg # 顶部 │ └── bottom.jpg # 底部 ├── SKU002_雪碧/ │ ├── front.jpg │ └── ... importosdefscan_product_images(root_dir):扫描商品图片目录返回(图片路径, 商品ID)列表samples[]forproduct_folderinos.listdir(root_dir):folder_pathos.path.join(root_dir,product_folder)ifnotos.path.isdir(folder_path):continue# 文件夹名格式: SKU001_可乐product_idproduct_folder.split(_)[0]product_name_.join(product_folder.split(_)[1:])forimg_fileinos.listdir(folder_path):ifimg_file.lower().endswith((.jpg,.jpeg,.png)):img_pathos.path.join(folder_path,img_file)samples.append({path:img_path,product_id:product_id,product_name:product_name,angle:os.path.splitext(img_file)[0]})returnsamples samplesscan_product_images(product_images)print(f共扫描到{len(samples)}张商品图片)2.2 特征提取模型选择特征提取模型是整个系统的核心。不同的模型在精度、速度、维度上有不同的trade-off模型输出维度推理速度(CPU)特征质量适用场景ResNet502048慢高云端、高性能设备ResNet18512中中高嵌入式端、平衡选择MobileNetV3-Large960快中移动端、低延迟MobileNetV3-Small576最快低极低功耗设备无人售货柜一般用RK3588主板选ResNet18比较合适。下面用ResNet18做演示importtorchimporttorchvision.modelsasmodelsimporttorchvision.transformsastransformsfromPILimportImageimportnumpyasnpclassFeatureExtractor:特征提取器将图片转为向量def__init__(self,model_nameresnet18):# 加载预训练模型去掉最后的分类层ifmodel_nameresnet18:base_modelmodels.resnet18(pretrainedTrue)self.dimension512elifmodel_nameresnet50:base_modelmodels.resnet50(pretrainedTrue)self.dimension2048elifmodel_namemobilenet_v3_small:base_modelmodels.mobilenet_v3_small(pretrainedTrue)self.dimension576else:raiseValueError(f不支持的模型:{model_name})# 去掉分类头保留特征提取部分self.modeltorch.nn.Sequential(*list(base_model.children())[:-1])self.model.eval()# 标准预处理self.transformtransforms.Compose([transforms.Resize((224,224)),transforms.ToTensor(),transforms.Normalize(mean[0.485,0.456,0.406],std[0.229,0.224,0.225])])defextract(self,image): 提取图片特征 :param image: PIL Image对象或图片路径 :return: 归一化后的特征向量 (numpy array) ifisinstance(image,str):imageImage.open(image).convert(RGB)img_tensorself.transform(image).unsqueeze(0)withtorch.no_grad():featureself.model(img_tensor)# 展平并L2归一化featurefeature.squeeze().numpy().astype(float32)normnp.linalg.norm(feature)ifnorm0:featurefeature/normreturnfeature extractorFeatureExtractor(resnet18)为什么要L2归一化因为归一化后向量之间的欧式距离和余弦距离是等价的FAISS检索时用L2距离就能反映余弦相似度。这是向量检索中常用的技巧。2.3 特征入库与元数据关联importfaissimportjsondefbuild_feature_database(samples,extractor,index_path,metadata_path): 构建特征库 :param samples: 商品图片样本列表 :param extractor: 特征提取器 :param index_path: FAISS索引文件保存路径 :param metadata_path: 元数据文件保存路径 all_features[]all_metadata[]print(f开始提取特征共{len(samples)}张图片...)fori,sampleinenumerate(samples):featureextractor.extract(sample[path])all_features.append(feature)all_metadata.append({product_id:sample[product_id],product_name:sample[product_name],angle:sample[angle],image_path:sample[path]})if(i1)%1000:print(f 已处理{i1}/{len(samples)})features_arraynp.array(all_features).astype(float32)dimensionfeatures_array.shape[1]# 构建FAISS索引# 数据量小于1000时用Flat索引暴力搜索精度最高# 数据量大时换IVF索引iflen(features_array)1000:indexfaiss.IndexFlatIP(dimension)# 内积余弦相似度(归一化后)else:nlistmin(256,len(features_array)//10)quantizerfaiss.IndexFlatIP(dimension)indexfaiss.IndexIVFFlat(quantizer,dimension,nlist,faiss.METRIC_INNER_PRODUCT)index.train(features_array)index.add(features_array)# 保存faiss.write_index(index,index_path)withopen(metadata_path,w,encodingutf-8)asf:json.dump(all_metadata,f,ensure_asciiFalse,indent2)print(f特征库构建完成:)print(f 总记录数:{index.ntotal})print(f 向量维度:{dimension})print(f 索引文件:{os.path.getsize(index_path)/1024:.1f}KB)returnindex,all_metadata# 执行构建index,metadatabuild_feature_database(samples,extractor,product_index.faiss,product_metadata.json)三、在线检索流程3.1 YOLO检测目标区域在线检索的第一步不是直接提特征而是先检测画面中哪里有物体。为什么因为摄像头拍到的画面里有背景、有货架、有多个商品直接整图提特征会被背景干扰检索结果一塌糊涂。fromultralyticsimportYOLOclassObjectDetector:YOLO目标检测器def__init__(self,model_pathyolov8n.pt):self.modelYOLO(model_path)defdetect(self,image): 检测图片中的物体 :param image: 图片路径或numpy数组 :return: [(x1,y1,x2,y2, confidence, class_name), ...] resultsself.model(image,conf0.5)detections[]forresultinresults:boxesresult.boxesforboxinboxes:x1,y1,x2,y2box.xyxy[0].cpu().numpy()confidencebox.conf[0].cpu().numpy()class_idint(box.cls[0].cpu().numpy())class_nameresult.names[class_id]detections.append({bbox:(int(x1),int(y1),int(x2),int(y2)),confidence:float(confidence),class_name:class_name})returndetections detectorObjectDetector(yolov8n.pt)3.2 裁剪目标 → 提取特征 → 向量检索importcv2classVisualSearchEngine:视觉检索引擎整合检测特征提取向量检索def__init__(self,index_path,metadata_path,detector_modelyolov8n.pt,feature_modelresnet18):# 加载向量索引self.indexfaiss.read_index(index_path)withopen(metadata_path,r,encodingutf-8)asf:self.metadatajson.load(f)# 初始化检测器和特征提取器self.detectorObjectDetector(detector_model)self.extractorFeatureExtractor(feature_model)print(f检索引擎就绪:{self.index.ntotal}条特征记录)defsearch_image(self,image_path,top_k5): 完整的图片检索流程 :param image_path: 输入图片路径 :param top_k: 每个目标返回前K个匹配结果 :return: 检测和检索结果列表 # 第1步YOLO检测detectionsself.detector.detect(image_path)print(f检测到{len(detections)}个目标)# 读取原图用于裁剪imagecv2.imread(image_path)image_rgbcv2.cvtColor(image,cv2.COLOR_BGR2RGB)results[]fori,detinenumerate(detections):x1,y1,x2,y2det[bbox]# 第2步裁剪目标区域cropimage_rgb[y1:y2,x1:x2]crop_pilImage.fromarray(crop)# 第3步提取特征featureself.extractor.extract(crop_pil)# 第4步向量检索querynp.array([feature]).astype(float32)distances,indicesself.index.search(query,top_k)# 整理检索结果matches[]forjinrange(top_k):idxindices[0][j]ifidx0:matchself.metadata[idx].copy()match[similarity]float(distances[0][j])matches.append(match)results.append({detection:det,matches:matches})# 打印结果ifmatches:bestmatches[0]print(f 目标{i1}:{det[class_name]}f→ 最匹配:{best[product_name]}f(相似度:{best[similarity]:.4f}))returnresults# 使用示例engineVisualSearchEngine(index_pathproduct_index.faiss,metadata_pathproduct_metadata.json)resultsengine.search_image(test_shelf.jpg,top_k5)四、检索精度优化4.1 多特征融合单个角度的特征可能不够稳定。可以把一个商品的多个角度特征取平均作为该商品的原型向量defbuild_centroid_features(metadata,extractor): 为每个商品构建中心特征多角度特征的平均 同一商品的多张图片特征取平均得到更稳定的表示 # 按商品ID分组product_features{}foriteminmetadata:piditem[product_id]ifpidnotinproduct_features:product_features[pid][]featureextractor.extract(item[image_path])product_features[pid].append(feature)# 每个商品取平均特征centroids[]centroid_metadata[]forpid,featuresinproduct_features.items():centroidnp.mean(features,axis0)centroidcentroid/np.linalg.norm(centroid)# 重新归一化centroids.append(centroid)centroid_metadata.append({product_id:pid,product_name:features[0][product_name]ifisinstance(features[0],dict)elsepid})returnnp.array(centroids).astype(float32),centroid_metadata4.2 重排序Re-ranking向量检索返回TopK结果后对前几个结果做更精细的比对提升精度defrerank_results(query_feature,candidate_features,candidate_metadata,top_k5): 重排序对初步检索结果做二次精排 策略用更精细的距离计算方式重新排序 # 初步检索可能用了近似索引IVFPQ有精度损失# 重排序时用精确距离重新计算distances[]fori,featinenumerate(candidate_features):# 使用余弦距离dist1-np.dot(query_feature,feat)distances.append((dist,i))# 按距离排序distances.sort(keylambdax:x[0])# 返回重排后的结果reranked[]fordist,idxindistances[:top_k]:resultcandidate_metadata[idx].copy()result[rerank_similarity]1-dist reranked.append(result)returnreranked4.3 置信度阈值过滤# 检索结果不是100%靠谱的需要设阈值过滤低置信度匹配SIMILARITY_THRESHOLD0.75# 经验值需根据实际数据调整deffilter_results(matches,thresholdSIMILARITY_THRESHOLD):过滤低置信度的匹配结果filtered[mforminmatchesifm[similarity]threshold]ifnotfiltered:returnNone# 没有匹配到任何商品returnfiltered[0]# 返回最佳匹配五、无人售货柜即拿即识实战把前面的模块串起来就是一个完整的售货柜商品识别流程classSmartShelfRecognizer:无人售货柜智能货架识别器def__init__(self,index_path,metadata_path):self.engineVisualSearchEngine(index_path,metadata_path)self.confidence_threshold0.75# 记录上一次识别结果用于判断拿取动作self.previous_itemsset()defrecognize(self,camera_image): 识别当前货架上的商品 :param camera_image: 摄像头拍摄的图片 :return: 当前货架上的商品列表 resultsself.engine.search_image(camera_image,top_k3)current_items[]forresultinresults:matchesresult[matches]ifmatchesandmatches[0][similarity]self.confidence_threshold:current_items.append({product_id:matches[0][product_id],product_name:matches[0][product_name],bbox:result[detection][bbox],confidence:matches[0][similarity]})# 对比上次结果判断变化current_ids{item[product_id]foritemincurrent_items}takenself.previous_items-current_ids# 消失的商品被拿走addedcurrent_ids-self.previous_items# 新出现的商品被放回self.previous_itemscurrent_idsreturn{current_items:current_items,taken_items:list(taken),# 用户拿走的商品added_items:list(added),# 用户放回的商品total_count:len(current_items)}# 实际部署recognizerSmartShelfRecognizer(product_index.faiss,product_metadata.json)# 模拟摄像头拍照识别resultrecognizer.recognize(shelf_current.jpg)print(f货架上现有{result[total_count]}件商品:)foriteminresult[current_items]:print(f{item[product_name]}(置信度:{item[confidence]:.2%}))ifresult[taken_items]:print(f用户拿走了:{result[taken_items]})六、性能数据与优化建议在RK3588上的实测数据1000个SKU每个SKU 6张图共6000条特征环节耗时说明YOLO检测25msyolov8n640×640输入特征提取(单目标)18msResNet18FAISS检索0.8msIVFFlat, 512维端到端(单目标)~45ms检测裁剪提特征检索端到端(10目标)~200ms10个商品并行提特征优化建议YOLO和特征提取用NPU加速RK3588的NPU跑ResNet18可以快3-5倍用RKNN Toolkit转换模型多目标特征提取批处理把多个裁剪区域组成batch一起推理减少IO开销索引预热程序启动时先跑几次假查询让FAISS的缓存热起来特征库定期更新新增商品时重新构建索引别在运行时动态add会有线程安全问题视觉检索的核心思路就四个字检测、提特征、检索、决策。每一步都有优化空间但最关键的是特征提取模型的质量——模型不行索引再花哨也白搭。如果是特定领域的商品比如药品包装建议用自有数据微调模型比通用预训练模型精度高得多。