🚀 项目背景
在零售行业,货架陈列面分析是商品管理和营销决策的重要依据。排面占比指的是某商品在货架上的陈列面积占总陈列面积的比例,直接关系到商品的曝光率和销售潜力。传统的人工统计方式耗时耗力,且难以保证准确性。本文基于YOLO系列目标检测算法,开发一套自动化货架商品检测与排面占比分析系统,支持YOLOv5/v8/v10三种主流模型,并提供友好的UI界面。
项目难点
商品种类繁多,外观相似度高
货架图像中存在遮挡、光照不均问题
排面占比需要精确计算每个商品的检测框面积
实时性要求与检测精度的平衡
📊 数据集构建
数据采集
我们采集了某大型超市的5000张货架图像,包含:
-
饮料区:1500张
-
零食区:2000张
-
日用品区:1500张
标注规范
使用LabelImg进行标注,标注类别包括:
python
CLASSES = [
'coca_cola', # 可口可乐
'pepsi', # 百事可乐
'lays', # 乐事薯片
'oreo', # 奥利奥
'noodle', # 方便面
'water', # 矿泉水
'juice', # 果汁
'chocolate', # 巧克力
'cookie', # 饼干
'yogurt' # 酸奶
]
数据集划分
python
# 数据集统计
total_images = 5000
train_count = 4000 # 80%
val_count = 500 # 10%
test_count = 500 # 10%
print(f"训练集: {train_count}张")
print(f"验证集: {val_count}张")
print(f"测试集: {test_count}张")
数据增强
python
import albumentations as A
transform = A.Compose([
A.RandomBrightnessContrast(p=0.5),
A.HueSaturationValue(p=0.3),
A.Rotate(limit=15, p=0.5),
A.HorizontalFlip(p=0.5),
A.GaussNoise(var_limit=(10.0, 50.0), p=0.3),
])
🤖 YOLO系列模型详解
YOLOv5
YOLOv5由Ultralytics于2020年发布,采用CSPDarknet作为骨干网络,具有以下特点:
-
自适应锚框计算
-
Mosaic数据增强
-
Focus层代替传统卷积
-
多种模型规模(n/s/m/l/x)
YOLOv8
2023年发布的YOLOv8是当前最流行的版本:
-
无锚框检测头
-
C2f模块替代C3
-
Decoupled Head
-
支持分类、检测、分割、姿态估计
YOLOv10
2024年最新发布的YOLOv10:
-
NMS-free训练策略
-
轻量级分类头
-
空间通道解耦下采样
-
大核卷积优化
💻 环境配置
创建虚拟环境
bash
conda create -n yolo_shelf python=3.9
conda activate yolo_shelf
安装依赖
bash
# 基础依赖
pip install torch torchvision torchaudio –index-url https://download.pytorch.org/whl/cu118
pip install opencv-python pillow matplotlib numpy pandas tqdm
# YOLOv5依赖
git clone https://github.com/ultralytics/yolov5
cd yolov5
pip install -r requirements.txt
# YOLOv8/v10依赖
pip install ultralytics
pip install yolov10 # 安装YOLOv10
🔧 YOLOv5实现
数据准备
python
# dataset.yaml
path: ./dataset # 数据集根目录
train: images/train # 训练集路径
val: images/val # 验证集路径
test: images/test # 测试集路径
nc: 10 # 类别数量
names: ['coca_cola', 'pepsi', 'lays', 'oreo', 'noodle', 'water', 'juice', 'chocolate', 'cookie', 'yogurt']
模型训练
python
import torch
from yolov5 import train
# 训练配置
train.run(
data='dataset.yaml',
weights='yolov5s.pt',
batch_size=16,
epochs=100,
imgsz=640,
device=0,
project='runs/train',
name='yolov5_shelf',
exist_ok=True
)
模型推理
python
import torch
import cv2
import numpy as np
class YOLOv5Detector:
def __init__(self, weights_path='best.pt'):
self.model = torch.hub.load('ultralytics/yolov5', 'custom',
path=weights_path, force_reload=True)
self.model.conf = 0.5 # 置信度阈值
self.model.iou = 0.45 # IOU阈值
def detect(self, image_path):
"""检测单张图片"""
results = self.model(image_path)
detections = results.pandas().xyxy[0] # 转为pandas DataFrame
return detections
def detect_batch(self, image_paths):
"""批量检测"""
results = self.model(image_paths)
return results
def visualize(self, image, detections):
"""可视化检测结果"""
img = image.copy()
for _, det in detections.iterrows():
x1, y1, x2, y2 = int(det['xmin']), int(det['ymin']), int(det['xmax']), int(det['ymax'])
conf = det['confidence']
cls = int(det['class'])
label = f"{self.model.names[cls]}: {conf:.2f}"
# 绘制边界框
cv2.rectangle(img, (x1, y1), (x2, y2), (0, 255, 0), 2)
cv2.putText(img, label, (x1, y1-10), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 2)
return img
🚀 YOLOv8实现
训练代码
python
from ultralytics import YOLO
import torch
class YOLOv8Trainer:
def __init__(self, model_name='yolov8s.pt'):
self.model = YOLO(model_name)
def train(self, data_yaml, epochs=100, batch_size=16, imgsz=640):
"""训练YOLOv8模型"""
results = self.model.train(
data=data_yaml,
epochs=epochs,
batch=batch_size,
imgsz=imgsz,
device=0,
workers=8,
lr0=0.01,
augment=True,
patience=50,
save_period=10,
project='runs/train',
name='yolov8_shelf'
)
return results
def validate(self, data_yaml):
"""验证模型"""
metrics = self.model.val(data=data_yaml)
return metrics
def export_model(self, format='onnx'):
"""导出模型"""
self.model.export(format=format)
# 开始训练
trainer = YOLOv8Trainer('yolov8s.pt')
trainer.train('dataset.yaml', epochs=100)
推理代码
python
from ultralytics import YOLO
import cv2
import numpy as np
class YOLOv8Detector:
def __init__(self, weights_path='best.pt'):
self.model = YOLO(weights_path)
self.class_names = self.model.names
def detect(self, image_path, conf_thres=0.5, iou_thres=0.45):
"""执行目标检测"""
results = self.model(image_path, conf=conf_thres, iou=iou_thres)
detections = []
for result in results:
boxes = result.boxes
if boxes is not None:
for box in boxes:
x1, y1, x2, y2 = box.xyxy[0].tolist()
conf = float(box.conf[0])
cls = int(box.cls[0])
detections.append({
'bbox': [x1, y1, x2, y2],
'confidence': conf,
'class_id': cls,
'class_name': self.class_names[cls]
})
return detections
def detect_video(self, video_path, output_path=None):
"""视频检测"""
cap = cv2.VideoCapture(video_path)
if output_path:
fourcc = cv2.VideoWriter_fourcc(*'mp4v')
fps = int(cap.get(cv2.CAP_PROP_FPS))
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
out = cv2.VideoWriter(output_path, fourcc, fps, (width, height))
while cap.isOpened():
ret, frame = cap.read()
if not ret:
break
# 检测
results = self.model(frame)
annotated_frame = results[0].plot()
if output_path:
out.write(annotated_frame)
else:
cv2.imshow('Detection', annotated_frame)
if cv2.waitKey(1) & 0xFF == ord('q'):
break
cap.release()
if output_path:
out.release()
cv2.destroyAllWindows()
⚡ YOLOv10实现
安装与训练
python
# 安装YOLOv10
pip install git+https://github.com/THU-MIG/yolov10.git
# 训练脚本
from yolov10 import train
import yaml
# 训练配置
train_args = {
'data': 'dataset.yaml',
'weights': 'yolov10s.pt',
'epochs': 100,
'batch_size': 16,
'imgsz': 640,
'device': 0,
'project': 'runs/train',
'name': 'yolov10_shelf'
}
train.run(**train_args)
推理代码
python
import torch
import cv2
import numpy as np
from yolov10.models import YOLOv10
from yolov10.utils import non_max_suppression, scale_coords
class YOLOv10Detector:
def __init__(self, weights_path='best.pt', device='cuda'):
self.device = torch.device(device)
self.model = YOLOv10(weights_path).to(self.device)
self.model.eval()
# 加载类别名称
with open('classes.txt', 'r') as f:
self.class_names = [line.strip() for line in f.readlines()]
def preprocess(self, image):
"""图像预处理"""
img = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
img = cv2.resize(img, (640, 640))
img = img.transpose(2, 0, 1) # HWC to CHW
img = np.ascontiguousarray(img)
img = torch.from_numpy(img).to(self.device)
img = img.float() / 255.0 # 归一化
if img.ndimension() == 3:
img = img.unsqueeze(0)
return img
def detect(self, image, conf_thres=0.5, iou_thres=0.45):
"""执行检测"""
# 预处理
img_tensor = self.preprocess(image)
# 推理
with torch.no_grad():
pred = self.model(img_tensor)[0]
# NMS
pred = non_max_suppression(pred, conf_thres, iou_thres)
detections = []
if pred[0] is not None:
for *xyxy, conf, cls in pred[0]:
detections.append({
'bbox': [float(x) for x in xyxy],
'confidence': float(conf),
'class_id': int(cls),
'class_name': self.class_names[int(cls)]
})
return detections
def visualize(self, image, detections):
"""可视化结果"""
img = image.copy()
for det in detections:
x1, y1, x2, y2 = [int(x) for x in det['bbox']]
conf = det['confidence']
label = f"{det['class_name']}: {conf:.2f}"
# 随机颜色
color = tuple(np.random.randint(0, 255, 3).tolist())
cv2.rectangle(img, (x1, y1), (x2, y2), color, 2)
cv2.putText(img, label, (x1, y1-10),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 2)
return img
📈 排面占比计算算法
核心算法
python
import numpy as np
import pandas as pd
from collections import defaultdict
class ShelfSpaceAnalyzer:
def __init__(self):
self.area_ratios = {}
def calculate_area(self, bbox):
"""计算单个检测框面积"""
x1, y1, x2, y2 = bbox
width = x2 – x1
height = y2 – y1
return width * height
def calculate_shelf_ratio(self, detections, image_area=None):
"""
计算排面占比
detections: 检测结果列表
image_area: 图像总面积(可选)
"""
if not detections:
return {}
# 按类别分组
class_areas = defaultdict(float)
total_area = 0
for det in detections:
area = self.calculate_area(det['bbox'])
class_name = det['class_name']
class_areas[class_name] += area
total_area += area
# 如果提供了图像总面积,使用图像面积;否则使用检测框总面积
if image_area and image_area > 0:
denominator = image_area
else:
denominator = total_area
# 计算占比
ratios = {}
for class_name, area in class_areas.items():
ratios[class_name] = (area / denominator) * 100
# 按占比降序排序
ratios = dict(sorted(ratios.items(), key=lambda x: x[1], reverse=True))
return ratios
def analyze_multi_images(self, detections_list):
"""分析多张图像的平均占比"""
all_ratios = []
for detections in detections_list:
ratios = self.calculate_shelf_ratio(detections)
all_ratios.append(ratios)
# 计算平均占比
avg_ratios = defaultdict(float)
count_ratios = defaultdict(int)
for ratios in all_ratios:
for class_name, ratio in ratios.items():
avg_ratios[class_name] += ratio
count_ratios[class_name] += 1
for class_name in avg_ratios:
avg_ratios[class_name] /= count_ratios[class_name]
return dict(sorted(avg_ratios.items(), key=lambda x: x[1], reverse=True))
def generate_report(self, ratios, output_path='shelf_report.csv'):
"""生成分析报告"""
df = pd.DataFrame(list(ratios.items()), columns=['商品类别', '排面占比(%)'])
df['排面占比(%)'] = df['排面占比(%)'].round(2)
df.to_csv(output_path, index=False, encoding='utf-8-sig')
return df
def visualize_ratios(self, ratios, save_path=None):
"""可视化占比结果"""
import matplotlib.pyplot as plt
plt.figure(figsize=(12, 6))
categories = list(ratios.keys())
values = list(ratios.values())
# 创建条形图
bars = plt.bar(categories, values, color='skyblue', edgecolor='navy')
# 添加数值标签
for bar, value in zip(bars, values):
plt.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 0.5,
f'{value:.1f}%', ha='center', va='bottom')
plt.xlabel('商品类别')
plt.ylabel('排面占比 (%)')
plt.title('货架商品排面占比分析')
plt.xticks(rotation=45, ha='right')
plt.tight_layout()
if save_path:
plt.savefig(save_path, dpi=300, bbox_inches='tight')
plt.show()
🎨 UI界面开发
主界面设计 (PyQt5)
python
import sys
import os
from PyQt5.QtWidgets import *
from PyQt5.QtCore import *
from PyQt5.QtGui import *
import cv2
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
from matplotlib.figure import Figure
class ShelfAnalyzerUI(QMainWindow):
def __init__(self):
super().__init__()
self.detector = None
self.current_image = None
self.detections = None
self.initUI()
def initUI(self):
"""初始化UI"""
self.setWindowTitle('货架排面占比分析系统')
self.setGeometry(100, 100, 1400, 800)
# 设置样式
self.setStyleSheet("""
QMainWindow {
background-color: #f0f0f0;
}
QPushButton {
background-color: #4CAF50;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
font-size: 14px;
}
QPushButton:hover {
background-color: #45a049;
}
QComboBox {
padding: 5px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
QLabel {
font-size: 14px;
}
""")
# 创建中央部件
central_widget = QWidget()
self.setCentralWidget(central_widget)
# 主布局
main_layout = QHBoxLayout()
central_widget.setLayout(main_layout)
# 左侧控制面板
left_panel = self.create_left_panel()
main_layout.addWidget(left_panel, 1)
# 右侧显示区域
right_panel = self.create_right_panel()
main_layout.addWidget(right_panel, 3)
def create_left_panel(self):
"""创建左侧控制面板"""
panel = QWidget()
layout = QVBoxLayout()
panel.setLayout(layout)
panel.setMaximumWidth(300)
# 模型选择
model_group = QGroupBox("模型选择")
model_layout = QVBoxLayout()
self.model_combo = QComboBox()
self.model_combo.addItems(['YOLOv5', 'YOLOv8', 'YOLOv10'])
self.model_combo.currentTextChanged.connect(self.on_model_changed)
model_layout.addWidget(QLabel("选择检测模型:"))
model_layout.addWidget(self.model_combo)
# 权重文件选择
weight_layout = QHBoxLayout()
self.weight_path = QLineEdit()
self.weight_path.setPlaceholderText("选择权重文件…")
weight_btn = QPushButton("浏览")
weight_btn.clicked.connect(self.browse_weights)
weight_layout.addWidget(self.weight_path)
weight_layout.addWidget(weight_btn)
model_layout.addLayout(weight_layout)
model_group.setLayout(model_layout)
layout.addWidget(model_group)
# 图像输入
input_group = QGroupBox("图像输入")
input_layout = QVBoxLayout()
# 单张图片
single_img_btn = QPushButton("选择单张图片")
single_img_btn.clicked.connect(self.load_single_image)
input_layout.addWidget(single_img_btn)
# 批量处理
batch_btn = QPushButton("批量处理文件夹")
batch_btn.clicked.connect(self.load_batch_images)
input_layout.addWidget(batch_btn)
# 视频处理
video_btn = QPushButton("处理视频")
video_btn.clicked.connect(self.load_video)
input_layout.addWidget(video_btn)
input_group.setLayout(input_layout)
layout.addWidget(input_group)
# 检测参数
param_group = QGroupBox("检测参数")
param_layout = QFormLayout()
self.conf_thres = QDoubleSpinBox()
self.conf_thres.setRange(0.1, 1.0)
self.conf_thres.setValue(0.5)
self.conf_thres.setSingleStep(0.05)
param_layout.addRow("置信度阈值:", self.conf_thres)
self.iou_thres = QDoubleSpinBox()
self.iou_thres.setRange(0.1, 1.0)
self.iou_thres.setValue(0.45)
self.iou_thres.setSingleStep(0.05)
param_layout.addRow("IOU阈值:", self.iou_thres)
param_group.setLayout(param_layout)
layout.addWidget(param_group)
# 操作按钮
detect_btn = QPushButton("开始检测")
detect_btn.clicked.connect(self.run_detection)
detect_btn.setStyleSheet("""
QPushButton {
background-color: #2196F3;
font-size: 16px;
padding: 12px;
}
QPushButton:hover {
background-color: #1976D2;
}
""")
layout.addWidget(detect_btn)
analyze_btn = QPushButton("分析排面占比")
analyze_btn.clicked.connect(self.analyze_shelf_ratio)
analyze_btn.setStyleSheet("""
QPushButton {
background-color: #FF9800;
font-size: 16px;
padding: 12px;
}
QPushButton:hover {
background-color: #F57C00;
}
""")
layout.addWidget(analyze_btn)
# 导出选项
export_group = QGroupBox("导出结果")
export_layout = QVBoxLayout()
export_csv_btn = QPushButton("导出CSV报告")
export_csv_btn.clicked.connect(self.export_csv)
export_layout.addWidget(export_csv_btn)
export_img_btn = QPushButton("导出检测图像")
export_img_btn.clicked.connect(self.export_image)
export_layout.addWidget(export_img_btn)
export_group.setLayout(export_layout)
layout.addWidget(export_group)
# 添加弹簧
layout.addStretch()
return panel
def create_right_panel(self):
"""创建右侧显示区域"""
panel = QWidget()
layout = QVBoxLayout()
panel.setLayout(layout)
# 选项卡
tabs = QTabWidget()
# 图像显示选项卡
image_tab = QWidget()
image_layout = QVBoxLayout()
self.image_label = QLabel()
self.image_label.setAlignment(Qt.AlignCenter)
self.image_label.setMinimumSize(800, 500)
self.image_label.setStyleSheet("border: 1px solid #ddd; background-color: white;")
image_layout.addWidget(self.image_label)
image_tab.setLayout(image_layout)
# 分析结果选项卡
result_tab = QWidget()
result_layout = QVBoxLayout()
# Matplotlib图表
self.figure = Figure(figsize=(8, 5))
self.canvas = FigureCanvas(self.figure)
result_layout.addWidget(self.canvas)
# 数据表格
self.result_table = QTableWidget()
self.result_table.setColumnCount(2)
self.result_table.setHorizontalHeaderLabels(['商品类别', '排面占比(%)'])
self.result_table.horizontalHeader().setStretchLastSection(True)
result_layout.addWidget(self.result_table)
result_tab.setLayout(result_layout)
tabs.addTab(image_tab, "图像显示")
tabs.addTab(result_tab, "分析结果")
layout.addWidget(tabs)
# 状态栏
self.status_label = QLabel("就绪")
self.status_label.setStyleSheet("color: green;")
layout.addWidget(self.status_label)
return panel
def browse_weights(self):
"""浏览权重文件"""
file_path, _ = QFileDialog.getOpenFileName(
self, "选择权重文件", "", "Model Files (*.pt)")
if file_path:
self.weight_path.setText(file_path)
def load_single_image(self):
"""加载单张图片"""
file_path, _ = QFileDialog.getOpenFileName(
self, "选择图片", "", "Image Files (*.jpg *.png *.jpeg)")
if file_path:
self.current_image = cv2.imread(file_path)
self.current_image_path = file_path
self.display_image(self.current_image)
self.status_label.setText(f"已加载图片: {os.path.basename(file_path)}")
def display_image(self, image):
"""在界面上显示图片"""
if image is None:
return
# 调整图像大小以适应显示区域
height, width = image.shape[:2]
max_height = 500
max_width = 800
if height > max_height or width > max_width:
scale = min(max_height/height, max_width/width)
new_width = int(width * scale)
new_height = int(height * scale)
image = cv2.resize(image, (new_width, new_height))
# 转换为QImage
rgb_image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
h, w, ch = rgb_image.shape
bytes_per_line = ch * w
qt_image = QImage(rgb_image.data, w, h, bytes_per_line, QImage.Format_RGB888)
# 显示
pixmap = QPixmap.fromImage(qt_image)
self.image_label.setPixmap(pixmap)
def run_detection(self):
"""运行目标检测"""
if self.current_image is None:
QMessageBox.warning(self, "警告", "请先加载图片!")
return
if not self.weight_path.text():
QMessageBox.warning(self, "警告", "请选择模型权重文件!")
return
try:
self.status_label.setText("正在检测中…")
QApplication.processEvents()
# 根据选择的模型创建检测器
model_name = self.model_combo.currentText()
if model_name == 'YOLOv5':
from yolov5_detector import YOLOv5Detector
self.detector = YOLOv5Detector(self.weight_path.text())
elif model_name == 'YOLOv8':
from yolov8_detector import YOLOv8Detector
self.detector = YOLOv8Detector(self.weight_path.text())
else: # YOLOv10
from yolov10_detector import YOLOv10Detector
self.detector = YOLOv10Detector(self.weight_path.text())
# 执行检测
self.detections = self.detector.detect(
self.current_image,
conf_thres=self.conf_thres.value(),
iou_thres=self.iou_thres.value()
)
# 显示检测结果
result_image = self.detector.visualize(self.current_image, self.detections)
self.display_image(result_image)
self.status_label.setText(f"检测完成!检测到 {len(self.detections)} 个目标")
except Exception as e:
QMessageBox.critical(self, "错误", f"检测失败:{str(e)}")
self.status_label.setText("检测失败")
def analyze_shelf_ratio(self):
"""分析排面占比"""
if self.detections is None:
QMessageBox.warning(self, "警告", "请先进行目标检测!")
return
# 计算排面占比
analyzer = ShelfSpaceAnalyzer()
image_area = self.current_image.shape[0] * self.current_image.shape[1]
ratios = analyzer.calculate_shelf_ratio(self.detections, image_area)
# 显示图表
self.figure.clear()
ax = self.figure.add_subplot(111)
categories = list(ratios.keys())
values = list(ratios.values())
bars = ax.bar(categories, values, color='skyblue', edgecolor='navy')
ax.set_xlabel('商品类别')
ax.set_ylabel('排面占比 (%)')
ax.set_title('货架商品排面占比分析')
ax.tick_params(axis='x', rotation=45)
# 添加数值标签
for bar, value in zip(bars, values):
ax.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 0.5,
f'{value:.1f}%', ha='center', va='bottom')
self.figure.tight_layout()
self.canvas.draw()
# 更新表格
self.result_table.setRowCount(len(ratios))
for i, (class_name, ratio) in enumerate(ratios.items()):
self.result_table.setItem(i, 0, QTableWidgetItem(class_name))
self.result_table.setItem(i, 1, QTableWidgetItem(f"{ratio:.2f}"))
QMessageBox.information(self, "完成", "排面占比分析完成!")
def export_csv(self):
"""导出CSV报告"""
if self.result_table.rowCount() == 0:
QMessageBox.warning(self, "警告", "没有可导出的数据!")
return
file_path, _ = QFileDialog.getSaveFileName(
self, "保存CSV文件", "", "CSV Files (*.csv)")
if file_path:
import pandas as pd
data = []
for i in range(self.result_table.rowCount()):
class_name = self.result_table.item(i, 0).text()
ratio = float(self.result_table.item(i, 1).text())
data.append([class_name, ratio])
df = pd.DataFrame(data, columns=['商品类别', '排面占比(%)'])
df.to_csv(file_path, index=False, encoding='utf-8-sig')
QMessageBox.information(self, "成功", f"报告已保存至:{file_path}")
def on_model_changed(self, model_name):
"""模型切换时的响应"""
if model_name == 'YOLOv5':
default_weights = 'yolov5s.pt'
elif model_name == 'YOLOv8':
default_weights = 'yolov8s.pt'
else:
default_weights = 'yolov10s.pt'
self.weight_path.setText(default_weights)



