
🎪 摸鱼匠:个人主页
🎒 个人专栏:《YOLOv11实战专栏》
🥇 没有好的理念,只有脚踏实地!

文章目录
-
- 一、零售智能化的敲门砖:为什么选择YOLOv11做商品检测
-
- [1.1 零售领域的痛点与技术选型的必然](#1.1 零售领域的痛点与技术选型的必然)
- [1.2 揭开SKU110K数据集的面纱](#1.2 揭开SKU110K数据集的面纱)
- [1.3 从零搭建你的实验环境](#1.3 从零搭建你的实验环境)
- 二、数据炼金术:SKU110K数据集的深度清洗与格式转换
-
- [2.1 原始数据的真相与挑战](#2.1 原始数据的真相与挑战)
- [2.2 编写工业级的数据转换脚本](#2.2 编写工业级的数据转换脚本)
- [2.3 数据集的"户籍管理":YAML配置文件详解](#2.3 数据集的“户籍管理”:YAML配置文件详解)
- 三、模型训练:从参数配置到实战演练
-
- [3.1 理解YOLOv11的训练参数玄学](#3.1 理解YOLOv11的训练参数玄学)
- [3.2 编写训练启动脚本](#3.2 编写训练启动脚本)
- [3.3 监控训练过程:读懂Loss曲线](#3.3 监控训练过程:读懂Loss曲线)
- 四、模型评估与优化:让结果更上一层楼
-
- [4.1 模型评估的核心指标:mAP与置信度阈值](#4.1 模型评估的核心指标:mAP与置信度阈值)
- [4.2 动手进行模型验证](#4.2 动手进行模型验证)
- [4.3 针对密集场景的NMS优化](#4.3 针对密集场景的NMS优化)
- 五、实战应用:货架分析与库存管理的逻辑构建
-
- [5.1 从检测框到货架层级的映射](#5.1 从检测框到货架层级的映射)
- [5.2 编写货架分析工具类](#5.2 编写货架分析工具类)
- [5.3 拓展应用:销售分析与客流热力图](#5.3 拓展应用:销售分析与客流热力图)
- 六、模型部署与工程化落地
-
- [6.1 模型格式转换:从PyTorch到ONNX](#6.1 模型格式转换:从PyTorch到ONNX)
- [6.2 TensorRT加速:让模型飞起来](#6.2 TensorRT加速:让模型飞起来)
- [6.3 编写一个简单的Flask API服务](#6.3 编写一个简单的Flask API服务)
- 七、总结与回顾
一、零售智能化的敲门砖:为什么选择YOLOv11做商品检测
1.1 零售领域的痛点与技术选型的必然
咱们做技术的,最怕的就是老板一句话:"咱们能不能搞个无人超市?"或者"能不能自动盘点一下库存?"这时候,你要是还在用传统图像处理那一套,比如OpenCV的边缘检测、模板匹配,那大概率是要熬夜到头秃的。传统的零售商品检测,难点太多了。货架上的商品五花八门,包装袋稍微有点褶皱,或者光线暗一点,传统的算法就歇菜了。更别提商品被遮挡、堆叠这些情况了。
这时候,深度学习目标检测就派上用场了。而在目标检测领域,YOLO(You Only Look Once)系列那可是如雷贯耳。为什么选YOLOv11?很简单,它"快、准、狠"。对于零售场景来说,实时性要求高,超市里的监控视频流得实时处理,你总不能让顾客拿了个商品,系统反应三秒钟才识别出来吧?YOLOv11继承了家族的优良传统,速度极快,同时精度还在不断提升。对于我们程序员来说,工程落地是王道,YOLOv11的部署生态非常成熟,不管是TensorRT加速还是OpenVINO推理,都有现成的工具链,这就省了我们不少事儿。
1.2 揭开SKU110K数据集的面纱
巧妇难为无米之炊,搞模型训练,数据集是核心。咱们这次用的SKU110K数据集,在零售检测圈子里那是相当有名气。它不是那种干干净净、只有几个商品的数据集,它是真的"硬核"。
SKU110K主要拍摄的是超市货架的密集场景。咱们得先搞清楚这个数据集的特点,才能对症下药。
| 特性维度 | 具体描述 | 对我们训练的影响 |
|---|---|---|
| 密集程度 | 极高。图片中全是密密麻麻的商品,一张图甚至有上百个目标。 | 检测头的设计要跟上,小目标检测能力要强,NMS(非极大值抑制)阈值得调,不然框全被滤掉了。 |
| 标注格式 | 通常提供的是XML文件(VOC格式)或者TXT文件,包含边界框坐标。 | 需要编写脚本将其转换为YOLO格式,这是个细致活,稍有不慎坐标就对不上。 |
| 场景多样性 | 包含不同的货架角度、光照条件、拍摄距离。 | 增强了模型的泛化能力,但也要求我们在数据增强时不能太激进,要模拟真实场景。 |
| 类别定义 | 实际上,SKU110K主要关注的是"商品"这一大类的检测,也就是on-shelf detection,细分类别不是重点。 | 我们训练时可以将其视为单类检测任务,降低分类难度,专注于定位精度。 |
咱们这次的任务,就是利用这个数据集的一个子集,把YOLOv11模型训练出来,让它能在这个密集的"货架丛林"里,精准地把每一个商品框出来。
1.3 从零搭建你的实验环境
在开始撸代码之前,咱们得先把"厨房"布置好。环境配置这事儿,虽然枯燥,但要是没弄好,后面全是坑。
首先,你的机器得有一张NVIDIA的显卡,显存最好是8G以上,毕竟SKU110K的图片分辨率不小,而且目标数量多,显存小了容易爆OOM(Out of Memory)。
操作系统推荐Ubuntu 20.04或者22.04,当然Windows 10/11也能跑,但Linux在训练稳定性上还是略胜一筹。
接下来是环境创建,咱们用Conda来管理,这可是Python程序员的福音。
bash
# 1. 创建一个新的虚拟环境,Python版本推荐3.10,比较稳健
conda create -n yolo_retail python=3.10 -y
# 2. 激活环境
conda activate yolo_retail
# 3. 安装PyTorch,这是重中之重
# 注意,这里要去PyTorch官网找对应的命令,一定要看准CUDA版本
# 比如你的显卡驱动支持CUDA 12.1,那就安装对应版本
# 这里我演示一个通用的安装命令,具体根据你的硬件情况调整
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121
# 4. 安装YOLOv11所在的ultralytics库
# 这个库封装得非常好,一行命令就能搞定
pip install ultralytics
# 5. 验证安装是否成功
python -c "from ultralytics import YOLO; print('YOLOv11 环境配置成功!')"
这个过程看似简单,但有几个雷区:
- CUDA版本匹配:很多人的显卡驱动很旧,却想装最新的PyTorch,结果报错。一定要确保你的驱动版本支持你所选的CUDA版本。
- 依赖冲突:不要在这个环境里乱装其他无关的包,特别是会影响系统库的包,保持环境纯净。
装好了环境,咱们就可以开始下一步了:把那堆乱七八糟的数据,变成模型能"吃"进去的营养餐。
二、数据炼金术:SKU110K数据集的深度清洗与格式转换
2.1 原始数据的真相与挑战
拿到SKU110K数据集的第一刻,千万别急着扔进去训练。你得先解压看看里面是什么德行。通常,下载下来的数据集包含三个文件夹:images(图片)、annotations(标注文件)、lists(划分列表)。
SKU110K的标注文件通常是XML格式的,这是Pascal VOC标准。里面记录了每个商品的坐标 ( x m i n , y m i n , x m a x , y m a x ) (x_{min}, y_{min}, x_{max}, y_{max}) (xmin,ymin,xmax,ymax)。但是!YOLOv11模型,它"吃"的是TXT格式,也就是每张图片对应一个TXT文件,里面每一行代表一个目标,格式是:
c l a s s _ i d x c e n t e r y c e n t e r w i d t h h e i g h t class\id \quad x{center} \quad y_{center} \quad width \quad height class_idxcenterycenterwidthheight
注意,这里的坐标都是归一化之后的相对坐标(0到1之间)。
这就给我们提出了第一个挑战:格式大迁徙。我们需要把XML里的绝对坐标,转换成YOLO需要的归一化中心点坐标。
咱们来看看这个转换的数学原理,虽然简单,但容不得半点马虎:
假设图片宽度为 W W W,高度为 H H H,VOC格式标注的框坐标为 ( x m i n , y m i n , x m a x , y m a x ) (x_{min}, y_{min}, x_{max}, y_{max}) (xmin,ymin,xmax,ymax)。
-
计算框的宽高 :
w b o x = x m a x − x m i n w_{box} = x_{max} - x_{min} wbox=xmax−xmin
h b o x = y m a x − y m i n h_{box} = y_{max} - y_{min} hbox=ymax−ymin -
计算框的中心点 :
x c e n t e r = x m i n + w b o x 2 x_{center} = x_{min} + \frac{w_{box}}{2} xcenter=xmin+2wbox
y c e n t e r = y m i n + h b o x 2 y_{center} = y_{min} + \frac{h_{box}}{2} ycenter=ymin+2hbox -
归一化处理 (这一步最关键):
x n o r m = x c e n t e r W x_{norm} = \frac{x_{center}}{W} xnorm=Wxcenter
y n o r m = y c e n t e r H y_{norm} = \frac{y_{center}}{H} ynorm=Hycenter
w n o r m = w b o x W w_{norm} = \frac{w_{box}}{W} wnorm=Wwbox
h n o r m = h b o x H h_{norm} = \frac{h_{box}}{H} hnorm=Hhbox
这就是数据预处理的核心逻辑。如果不做归一化,模型训练出来的框会飞到九霄云外去。
2.2 编写工业级的数据转换脚本
手动改几个文件还行,SKU110K动辄几千张图,必须写脚本自动化处理。下面这个脚本,我会写得尽量详细,加上各种容错机制,确保大家可以直接拿去用。
python
import os
import glob
import xml.etree.ElementTree as ET
from PIL import Image
from tqdm import tqdm # 这是一个进度条库,没装的pip install tqdm
# 定义输入输出路径
# 假设你的数据集解压在 './SKU110K' 文件夹下
SOURCE_IMAGES_DIR = './SKU110K/images'
SOURCE_ANNOTATIONS_DIR = './SKU110K/annotations'
OUTPUT_LABELS_DIR = './SKU110K/labels' # 转换后的YOLO格式标签存放位置
# 确保输出目录存在
os.makedirs(OUTPUT_LABELS_DIR, exist_ok=True)
# 定义类别映射
# SKU110K其实主要检测"商品",我们可以把它定义为类别0
# 如果数据集有细分类别,这里需要扩展字典,但对于基础商品检测,我们就统一为 'goods'
CLASS_DICT = {'goods': 0}
def convert_box(size, box):
"""
核心转换函数:将VOC格式的坐标转换为YOLO格式的归一化坐标
:param size: 图片尺寸
:param box: voc格式的坐标
:return: yolo格式的坐标
"""
dw = 1. / size[0] # 宽度的倒数,用于归一化
dh = 1. / size[1] # 高度的倒数
# 计算中心点和宽高
x_center = (box[0] + box[2]) / 2.0
y_center = (box[1] + box[3]) / 2.0
width = box[2] - box[0]
height = box[3] - box[1]
# 归一化
x = x_center * dw
y = y_center * dh
w = width * dw
h = height * dh
return (x, y, w, h)
def convert_annotation(image_id):
"""
解析单个XML文件并生成对应的TXT标签文件
"""
# 构建XML文件路径,这里假设文件名和图片ID对应
# SKU110K的文件命名可能比较特殊,比如 'train_0.jpg' 对应 'train_0.xml'
xml_file = os.path.join(SOURCE_ANNOTATIONS_DIR, f"{image_id}.xml")
# 如果对应的xml文件不存在,就跳过(处理测试集或无标注图片)
if not os.path.exists(xml_file):
# 有些数据集可能xml文件名不带后缀或者有细微差别,这里可以加容错逻辑
# 比如尝试查找类似 'train_0_anno.xml' 之类的,SKU110K通常是直接对应的
return
# 解析XML
try:
tree = ET.parse(xml_file)
root = tree.getroot()
except ET.ParseError as e:
print(f"Warning: 解析文件 {xml_file} 出错: {e}")
return
# 获取图片尺寸,这一步至关重要!
size = root.find('size')
if size is None:
# 有些数据集的XML里没有size,那就得用PIL打开图片看尺寸
img_path = glob.glob(os.path.join(SOURCE_IMAGES_DIR, f"{image_id}.*"))[0]
im = Image.open(img_path)
w, h = im.size
else:
w = int(size.find('width').text)
h = int(size.find('height').text)
# 打开输出的txt文件准备写入
out_file_path = os.path.join(OUTPUT_LABELS_DIR, f"{image_id}.txt")
with open(out_file_path, 'w') as out_file:
# 遍历所有目标对象
for obj in root.iter('object'):
# 获取类别名称,SKU110K里通常叫 'object' 或者具体的商品名
# 这里做兼容处理
cls_name = obj.find('name').text
if cls_name not in CLASS_DICT:
# 如果遇到了未定义的类别,可以动态添加或者跳过
# 这里为了演示,我们将所有未知类别都归为 'goods' 类
cls_name = 'goods'
cls_id = CLASS_DICT[cls_name]
# 获取边界框坐标
xmlbox = obj.find('bndbox')
# VOC格式: xmin, ymin, xmax, ymax
b = (float(xmlbox.find('xmin').text),
float(xmlbox.find('ymin').text),
float(xmlbox.find('xmax').text),
float(xmlbox.find('ymax').text))
# 调用转换函数
bb = convert_box((w, h), b)
# 写入文件,保留6位小数
out_file.write(f"{cls_id} {bb[0]:.6f} {bb[1]:.6f} {bb[2]:.6f} {bb[3]:.6f}\n")
# 主处理循环
def main():
print("开始数据格式转换...")
# 获取所有图片文件名(不带后缀)
# 这里要注意,SKU110K可能包含train, test, val子文件夹
# 我们需要递归遍历或者分别处理
image_files = []
# 假设我们处理的是训练集,图片都在 train 子目录下
# 实际操作时请根据解压后的目录结构调整 glob 模式
search_pattern = os.path.join(SOURCE_IMAGES_DIR, '**', '*.jpg')
image_files.extend(glob.glob(search_pattern, recursive=True))
print(f"共找到 {len(image_files)} 张图片,开始处理对应的标注文件...")
for img_path in tqdm(image_files):
# 提取文件名(不含后缀)
image_id = os.path.splitext(os.path.basename(img_path))[0]
convert_annotation(image_id)
print(f"转换完成!标签文件已保存至: {OUTPUT_LABELS_DIR}")
if __name__ == '__main__':
main()
这段代码虽然长,但逻辑非常清晰:找文件 -> 解析XML -> 算坐标 -> 写TXT。其中加了几个关键的容错:
- XML解析错误的捕获。
- XML中没有尺寸信息时的PIL读图备用方案。
- 类别名称的兼容处理。
跑完这个脚本,你的文件夹里就会多出来一堆TXT文件,这就是YOLOv11最喜欢吃的"食物"了。
2.3 数据集的"户籍管理":YAML配置文件详解
数据转换好了,还得告诉模型这些数据都在哪,有哪些类别。这时候就需要编写一个YAML配置文件。这个文件虽然短,但是连接数据和模型的桥梁。
我们在项目根目录下创建一个 retail_data.yaml:
yaml
# retail_data.yaml
# 数据集所在的根目录路径
# 这里建议使用绝对路径,避免因为运行路径不同导致找不到文件
path: /home/user/project/SKU110K # 替换成你自己的实际路径
# 相对于path的子目录路径
train: images/train # 训练集图片路径,实际是 path/images/train
val: images/val # 验证集图片路径
# 类别数量
nc: 1
# 类别名称
# 这里我们只有一个类别 'goods'
names:
0: goods
这个文件里有几个坑要注意:
- 路径问题 :80%的训练报错都是路径问题。
path一定要写对,或者直接在训练代码里指定绝对路径。 - 数据划分:刚才我们的脚本只是转换了格式,还没划分训练集和验证集。YOLOv11支持自动划分,但为了稳妥,建议手动划分。
下面是一个简单的脚本,帮你把图片和标签按照9:1的比例划分到 train 和 val 文件夹里:
python
import os
import shutil
import random
from tqdm import tqdm
# 原始数据路径
IMAGES_SRC = './SKU110K/images_all' # 假设所有图片都在这
LABELS_SRC = './SKU110K/labels' # 上一步生成的标签
# 目标路径
BASE_DIR = './SKU110K'
TRAIN_IMG = os.path.join(BASE_DIR, 'images', 'train')
VAL_IMG = os.path.join(BASE_DIR, 'images', 'val')
TRAIN_LBL = os.path.join(BASE_DIR, 'labels', 'train')
VAL_LBL = os.path.join(BASE_DIR, 'labels', 'val')
# 创建目录
for p in [TRAIN_IMG, VAL_IMG, TRAIN_LBL, VAL_LBL]:
os.makedirs(p, exist_ok=True)
# 获取所有图片并打乱
all_images = [f for f in os.listdir(IMAGES_SRC) if f.endswith('.jpg')]
random.shuffle(all_images)
# 划分比例
split_ratio = 0.9
split_idx = int(len(all_images) * split_ratio)
train_images = all_images[:split_idx]
val_images = all_images[split_idx:]
def move_files(file_list, img_dst, lbl_dst):
for f in tqdm(file_list):
# 移动图片
src_img = os.path.join(IMAGES_SRC, f)
dst_img = os.path.join(img_dst, f)
shutil.move(src_img, dst_img) # 或者用 shutil.copy
# 移动标签
lbl_name = f.replace('.jpg', '.txt')
src_lbl = os.path.join(LABELS_SRC, lbl_name)
if os.path.exists(src_lbl):
dst_lbl = os.path.join(lbl_dst, lbl_name)
shutil.move(src_lbl, dst_lbl)
print("正在划分训练集...")
move_files(train_images, TRAIN_IMG, TRAIN_LBL)
print("正在划分验证集...")
move_files(val_images, VAL_IMG, VAL_LBL)
print("数据集划分完成!")
这一步做完,你的目录结构就是标准的YOLO格式了:
text
SKU110K/
├── images/
│ ├── train/
│ └── val/
├── labels/
│ ├── train/
│ └── val/
└── retail_data.yaml
至此,数据准备工作大功告成,咱们可以开始真正的模型训练了。
三、模型训练:从参数配置到实战演练
3.1 理解YOLOv11的训练参数玄学
训练模型,某种程度上来说,是一门"炼丹"的艺术。参数调得好,效果翻倍;调不好,模型不收敛或者过拟合。YOLOv11虽然封装得很好,提供了很多默认参数,但针对零售货架这种密集场景,咱们还是得微调一下。
我们主要关注以下几个核心参数:
| 参数名 | 默认值 | 通俗解释 | 零售场景调优建议 |
|---|---|---|---|
| imgsz | 640 | 输入图片的尺寸,模型会把图片缩放到这个大小。 | SKU110K图片很大,且小目标多。建议设为 1280 甚至更高,宁可训练慢点,也要保证小商品不被漏检。 |
| batch | 16 | 一次塞给显卡多少张图。 | 取决于你的显存。如果是 imgsz=1280,显存不够的话,batch_size 可能得降到 4 或 8。 |
| epochs | 100 | 把整个数据集学习多少遍。 | 密集场景难训练,建议 200 往上走,让模型彻底"看透"货架。 |
| lr0 | 0.01 | 初始学习率,相当于步长。步子太大容易扯着蛋(发散),步子太小走得慢。 | 默认值一般可以,配合Cosine学习率衰减策略,效果不错。 |
| mosaic | True | 数据增强神器,把四张图拼成一张。 | 重点! 对于货架检测,Mosaic非常有效,能模拟密集堆叠效果,千万别关。 |
| overlap_mask | True | 处理重叠区域的掩码。 | 虽然我们做的是检测,但这个参数涉及到底层对重叠框的处理逻辑,保持默认即可。 |
还有一个特别重要的参数是 degrees 和 scale。货架上的商品摆放角度其实挺随机的,所以适当增加旋转增强(比如 degrees=45)是很有必要的。
3.2 编写训练启动脚本
我们不推荐在命令行里直接敲一长串命令,那样不好维护,也不好复现。咱们写一个Python脚本来启动训练,这样逻辑更清晰。
python
from ultralytics import YOLO
import torch
def main():
# 1. 检查显卡是否可用,这步不能少
if not torch.cuda.is_available():
print("警告:未检测到GPU,将使用CPU训练,速度会非常慢!")
device = 'cpu'
else:
device = 0 # 指定使用第0号显卡
print(f"检测到GPU: {torch.cuda.get_device_name(0)}")
# 2. 加载模型
# 这里我们有两种选择:
# a) 从头开始训练:model = YOLO('yolo11n.yaml')
# b) 加载预训练权重进行微调:model = YOLO('yolo11n.pt')
# 强烈建议选择,因为COCO数据集已经学到了很多通用特征,微调收敛快,效果好。
# 'n' 代表nano版本,速度快;如果是服务器端部署,可以用 's' (small) 或 'm' (medium)
model = YOLO('yolo11n.pt')
# 3. 开始训练
# 这里的参数就是我们刚才分析过的
results = model.train(
data='./retail_data.yaml', # 指定刚才写的配置文件
imgsz=1280, # 提高分辨率,为了看清小商品
epochs=200, # 训练轮数
batch=8, # batch size,根据显存调整
name='yolo11_retail_exp', # 实验名称,结果会保存在 runs/detect/yolo11_retail_exp
device=device, # 指定设备
patience=50, # 早停耐心,如果50轮没提升就停
save=True, # 保存检查点
save_period=10, # 每10轮保存一次,防止断电白练
project='runs/train', # 项目保存路径
# 下面是一些增强参数的微调,针对零售场景
degrees=15.0, # 随机旋转角度,货架可能有倾斜
translate=0.1, # 平移
scale=0.5, # 缩放比例,模拟远近不同的商品
shear=0.0, # 剪切变换
perspective=0.0, # 透视变换
flipud=0.0, # 上下翻转概率,商品倒过来就离谱了,设为0
fliplr=0.5, # 左右翻转概率,货架左右镜像还是合理的
mosaic=1.0, # Mosaic概率,必须拉满
mixup=0.1, # Mixup增强,混合图像,增加难度
)
# 4. 训练结束后,打印结果路径
print(f"训练完成!最佳模型保存在: runs/train/yolo11_retail_exp/weights/best.pt")
if __name__ == '__main__':
main()
代码功能深度剖析
- 模型加载 :
YOLO('yolo11n.pt')。这一步看似简单,背后其实是在下载官方的权重文件。如果网不好,可能会卡住。建议提前下载好.pt文件放到本地。 - 早停机制 :
patience=50。这是个很实用的功能。如果验证集的损失在50个epoch内一直没有下降,程序就会自动停止,防止过拟合,也帮你省钱。 - 数据增强组合拳 :
fliplr=0.5:商品左右翻转是合理的,毕竟货架左边看和右边看是对称的。flipud=0.0:这个千万别开!你见过倒着放的洗发水瓶子吗?开了这个,模型会学傻的。mosaic=1.0:这是YOLOv4以来的神技。把四张货架图拼在一起,能让模型在一个Batch里看到更多的商品上下文,对密集检测效果拔群。
运行这个脚本,你会看到一个进度条开始滚动,显卡风扇开始狂转。这时候,你可以去喝杯咖啡,或者看看TensorBoard的曲线。
3.3 监控训练过程:读懂Loss曲线
训练不是扔进去就不管了,咱们得盯着点。YOLOv11会自动生成 results.csv 和图表。
我们主要看这几个指标:
- box_loss:边界框回归损失。这个值越小,说明框的位置越准。在密集场景下,这个loss下降可能会比较慢,因为稍微偏一点点就可能盖住旁边的商品。
- cls_loss:分类损失。因为我们只有一个类(goods),这个值应该降得很快,并且接近0。
- dfl_loss:Distribution Focal Loss,这是YOLO系列用来处理回归问题的核心损失函数,帮助模型更好地定位目标。
如果发现训练集loss一直在降,验证集loss却开始上升,那就是过拟合了。这时候可以降低学习率,或者增加数据增强强度。
四、模型评估与优化:让结果更上一层楼
4.1 模型评估的核心指标:mAP与置信度阈值
训练完了,那个 best.pt 文件就是咱们的"宝贝"。但在用它之前,得先考考试,看看它到底考了多少分。
在目标检测里,最核心的指标是 mAP@0.5 和 mAP@0.5:0.95。
- mAP@0.5:当预测框和真实框的重叠度大于0.5时,就算预测正确。这是一个比较宽松的标准,及格线。对于货架检测,这个值通常应该很高,比如90%以上。
- mAP@0.5:0.95:这是一个严苛的标准。它要求IoU从0.5开始,每隔0.05计算一次AP,一直算到0.95,然后取平均。这考验的是模型能不能把框画得"严丝合缝"。对于零售来说,这个指标很重要,因为框画得准,才能准确判断商品是不是被拿走了。
还有一个关键概念是 置信度阈值。模型预测出来的框,都会带一个概率值,表示"我有多确定这是个商品"。如果阈值设得太高,比如0.9,那很多模糊的商品就被过滤掉了(召回率低);设得太低,比如0.1,那货架缝隙都会被当成商品(精确率低)。
4.2 动手进行模型验证
我们写个脚本,用验证集来测试模型,并绘制出那些漂亮的曲线图。
python
from ultralytics import YOLO
def evaluate_model():
# 1. 加载训练好的最佳模型
model = YOLO('./runs/train/yolo11_retail_exp/weights/best.pt')
# 2. 在验证集上运行评估
# verbose=True 可以打印详细信息
metrics = model.val(
data='./retail_data.yaml',
imgsz=1280,
conf=0.5, # 置信度阈值设为0.5,这是个常用的基准
iou=0.6, # NMS的IoU阈值
device=0,
plots=True, # 自动绘制PR曲线、混淆矩阵等
save_json=True # 保存结果为JSON,方便后续分析
)
# 3. 打印核心指标
print("\n" + "="*30)
print("模型评估结果汇总")
print("="*30)
print(f"mAP@0.5: {metrics.box.map50:.4f}")
print(f"mAP@0.5:0.95: {metrics.box.map:.4f}")
print(f"Precision (精确率): {metrics.box.mp:.4f}")
print(f"Recall (召回率): {metrics.box.mr:.4f}")
# 4. 混淆矩阵分析
# 结果文件夹里会有一个 confusion_matrix.png
# 我们要看的是:False Negative (漏检) 和 False Positive (误检)
# 对于零售,漏检(把商品当背景)比误检(把背景当商品)通常更严重
# 因为你不知道货少了,库存管理就乱了。
if __name__ == '__main__':
evaluate_model()
这段代码运行后,会生成 confusion_matrix.png 和 PR_curve.png。
- PR曲线:越往右上角凸越好。如果曲线像个大鼓包,说明模型强劲。
- 混淆矩阵:对角线越亮越好。看看"background -> goods"这一格(误检)和"goods -> background"这一格(漏检)的数值。
4.3 针对密集场景的NMS优化
YOLOv11默认使用的NMS(非极大值抑制)算法,是用来去除重复框的。但是在货架上,两个商品挨得特别近,NMS很容易误以为是一个目标,把其中一个框给删了。
这就需要我们调整 IoU阈值 或者使用 Soft-NMS。
YOLOv11的代码库里其实已经集成了高级的NMS策略,我们可以通过参数来控制。如果发现很多紧挨着的商品被漏检了,我们可以手动改一下推理代码里的 iou 参数,默认是0.7,我们可以降到0.5或0.4,让NMS更"宽容"一些。
或者,我们可以在导出模型时,使用更适合密集场景的设置。但最直接的方法,是在推理时进行微调。
python
def predict_with_custom_nms():
model = YOLO('./runs/train/yolo11_retail_exp/weights/best.pt')
# 运行预测
results = model.predict(
source='./SKU110K/images/val/test_image.jpg', # 单张图片测试
imgsz=1280,
conf=0.25, # 置信度稍微设低点,宁可错杀一千,不可放过一个
iou=0.4, # 这里调低IoU阈值,保留更多重叠框
max_det=300, # 最大检测数量,零售场景可能目标很多,默认100可能不够,得开大
device=0
)
# 结果可视化
for result in results:
# result.plot() 会把框画在图上并返回BGR格式的数组
annotated_frame = result.plot()
# 保存结果
import cv2
cv2.imwrite('./prediction_result.jpg', annotated_frame)
print(f"检测到 {len(result.boxes)} 个商品,结果已保存。")
# 打印具体的框坐标(用于后续的货架分析)
boxes = result.boxes.xywhn.cpu().numpy() # 获取归一化后的中心点宽高
print("部分商品坐标示例:", boxes[:5])
if __name__ == '__main__':
predict_with_custom_nms()
代码分析:
iou=0.4:这是个关键改动。默认值通常较高,容易删掉挨得近的框。调低后,只要两个框重叠度不是极其高,就都保留下来。max_det=300:默认值通常是100或300。如果你拍的是整个货架通道,商品可能有几百个,这个参数必须得设大,否则后面的商品检测不出来。
五、实战应用:货架分析与库存管理的逻辑构建
5.1 从检测框到货架层级的映射
仅仅把商品框出来,只是第一步。老板想要的是数据:哪个货架缺货了?哪种商品卖得好?
这就需要我们对检测到的框进行二次分析 。
我们得把一堆乱七八糟的坐标,组织成有序的"货架层级"。
这就涉及到一个几何变换问题。我们可以把货架看作一个网格。检测到的商品框的中心点 ( c x , c y ) (c_x, c_y) (cx,cy),我们可以根据 y y y 坐标(垂直位置)来对商品进行聚类。 y y y 坐标相近的商品,很可能在同一层货架上。
我们可以用简单的 K-Means聚类 或者 分位数切分 来实现这个逻辑。
YOLOv11推理
按Y坐标聚类
计算层内框的数量
OCR识别/特征匹配
原始图像
获得商品检测框
数据分析模块
分层货架信息
货架层饱和度分析
具体SKU识别
生成补货预警
销售热力图统计
上图展示了从原始图像到业务逻辑的流转过程。下面我们来用代码实现这个"货架分层"的逻辑。
5.2 编写货架分析工具类
这个工具类将读取模型的预测结果,然后输出每个货架层的商品密度。
python
import numpy as np
import cv2
from sklearn.cluster import KMeans
from ultralytics import YOLO
class ShelfAnalyzer:
def __init__(self, model_path, num_shelf_layers=5):
"""
初始化分析器
:param model_path: 模型路径
:param num_shelf_layers: 预估的货架层数,用于聚类
"""
self.model = YOLO(model_path)
self.num_layers = num_shelf_layers
def analyze_image(self, image_path):
# 1. 预测
results = self.model.predict(source=image_path, conf=0.25, iou=0.4, verbose=False)
if len(results) == 0:
return None
result = results[0]
# 2. 获取所有框的中心点坐标
# xyxy 格式是,我们取中心点
boxes_xyxy = result.boxes.xyxy.cpu().numpy()
if len(boxes_xyxy) == 0:
return None
centers = []
for box in boxes_xyxy:
x_center = (box[0] + box[2]) / 2
y_center = (box[1] + box[3]) / 2
centers.append([y_center, x_center]) # 注意:聚类主要看y,所以把y放前面
centers = np.array(centers)
# 3. 按Y坐标进行聚类,识别货架层
# 这里我们假设有5层货架,用K-Means把它们分成5堆
if len(centers) < self.num_layers:
print("检测到的商品太少,无法分层")
return None
kmeans = KMeans(n_clusters=self.num_layers, random_state=0, n_init='auto').fit(centers[:, 0].reshape(-1, 1))
labels = kmeans.labels_
# 4. 统计每一层的商品数量
layer_counts = {}
layer_boxes = {}
# 获取聚类中心并排序,确定哪是第一层,哪是最后一层
cluster_centers = kmeans.cluster_centers_.flatten()
# argsort返回的是排序后的索引,这就对应了层级的顺序
sorted_layer_indices = np.argsort(cluster_centers)
# 映射关系:聚类标签 -> 实际层级 (0, 1, 2...)
label_to_level = {old_idx: new_idx for new_idx, old_idx in enumerate(sorted_layer_indices)}
for i, label in enumerate(labels):
level = label_to_level[label]
if level not in layer_counts:
layer_counts[level] = 0
layer_boxes[level] = []
layer_counts[level] += 1
layer_boxes[level].append(boxes_xyxy[i])
return layer_counts, layer_boxes
def visualize_layers(self, image_path, layer_boxes):
"""
在图上把不同层级的商品框画出来,不同层级不同颜色
"""
img = cv2.imread(image_path)
colors = [
(255, 0, 0), (0, 255, 0), (0, 0, 255),
(255, 255, 0), (255, 0, 255)
] # 蓝、绿、红、青、洋红
for level, boxes in layer_boxes.items():
color = colors[level % len(colors)]
for box in boxes:
x1, y1, x2, y2 = map(int, box)
cv2.rectangle(img, (x1, y1), (x2, y2), color, 2)
# 写上层级编号
cv2.putText(img, f"L{level+1}", (x1, y1 - 5),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 2)
return img
# 使用示例
if __name__ == '__main__':
analyzer = ShelfAnalyzer('./runs/train/yolo11_retail_exp/weights/best.pt', num_shelf_layers=4)
counts, boxes = analyzer.analyze_image('./SKU110K/images/val/test_image.jpg')
print("货架层级分析结果:")
for level in sorted(counts.keys()):
print(f"第 {level+1} 层: 检测到 {counts[level]} 个商品")
# 可视化保存
vis_img = analyzer.visualize_layers('./SKU110K/images/val/test_image.jpg', boxes)
cv2.imwrite('./shelf_analysis_visual.jpg', vis_img)
这段代码的精髓在于 :
它把纯粹的视觉问题(检测框),转化为了结构化的数据问题(层级统计)。
K-Means聚类算法根据 y y y 轴坐标把商品分成了几堆。np.argsort确保了层级顺序是"从上到下"的,而不是随机的。- 最后输出的
layer_counts就可以直接发给仓库管理员:"嘿,第二层货架只有3个商品了,赶紧补货!"
5.3 拓展应用:销售分析与客流热力图
虽然我们的模型只检测"商品",但结合一些简单的逻辑,我们就能做更多事。
-
缺货率计算 :
缺货率 = 理论陈列数量 − 实际检测数量 理论陈列数量 \text{缺货率} = \frac{\text{理论陈列数量} - \text{实际检测数量}}{\text{理论陈列数量}} 缺货率=理论陈列数量理论陈列数量−实际检测数量我们可以设定一个阈值,比如缺货率超过 30%,系统自动报警。
-
陈列合规检查 :
很多品牌商要求自己的商品必须放在"黄金陈列位"(比如视线平行的货架层)。我们可以检查检测到的商品框是否落在了规定的区域。
例如,如果我们想检查某品牌是否在"黄金位置",我们可以定义一个感兴趣区域(ROI),然后计算检测框与ROI的重叠率。
python
def check_compliance(image_path, roi_coords, model):
"""
检查商品是否在指定的ROI区域内
:param roi_coords: (x1, y1, x2, y2) 黄金陈列位的坐标
"""
results = model.predict(image_path, conf=0.25, verbose=False)
img = cv2.imread(image_path)
# 画出ROI区域
cv2.rectangle(img, (roi_coords[0], roi_coords[1]),
(roi_coords[2], roi_coords[3]), (0, 255, 255), 3)
# 这里的IoU计算逻辑稍微简化
roi_area = (roi_coords[2] - roi_coords[0]) * (roi_coords[3] - roi_coords[1])
compliance_score = 0
for box in results[0].boxes.xyxy.cpu().numpy():
# 计算检测框与ROI的重叠面积
# ... (这里省略具体的IoU计算代码,逻辑是求两个矩形的交集)
# 如果重叠面积很大,说明商品确实在黄金位置
pass
# 最终输出一个合规评分
return img, compliance_score
六、模型部署与工程化落地
6.1 模型格式转换:从PyTorch到ONNX
训练好的 .pt 文件是PyTorch格式的,在Python里用着爽,但要是想集成到App里,或者用C++写高性能后端,就得转成通用格式。ONNX (Open Neural Network Exchange) 就是模型界的"世界语"。
YOLOv11提供了一个非常方便的导出接口,不需要我们再去写繁琐的转换脚本。
python
from ultralytics import YOLO
def export_to_onnx():
# 加载模型
model = YOLO('./runs/train/yolo11_retail_exp/weights/best.pt')
# 导出为ONNX格式
# opset=12 是版本号,通常越高支持的操作越多,但要考虑推理引擎的支持情况
# simplify=True 会用onnx-simplifier工具优化模型图,去掉冗余算子
success = model.export(
format='onnx',
imgsz=1280,
opset=12,
simplify=True,
device=0 # 在GPU上导出,速度更快
)
if success:
print(f"模型已成功导出为 ONNX 格式:{success}")
if __name__ == '__main__':
export_to_onnx()
导出成功后,你会得到一个 best.onnx 文件。这个文件不仅体积更小,而且加载速度更快,是工业部署的首选。
6.2 TensorRT加速:让模型飞起来
如果你是在NVIDIA显卡上做服务端部署,那TensorRT是绕不开的神器。它可以把模型进一步优化,通过层融合、精度校准(FP16/INT8)等技术,让推理速度翻几倍。
YOLOv11支持直接导出TensorRT引擎(.engine文件),但这需要你的环境里安装了TensorRT库。这个过程比较复杂,咱们简要提一下操作步骤:
python
# 需要先安装 tensorrt 库
# 导出脚本
def export_to_tensorrt():
model = YOLO('./runs/train/yolo11_retail_exp/weights/best.pt')
# 导出为TensorRT格式,自动执行FP16量化
success = model.export(
format='engine',
imgsz=1280,
half=True, # 开启FP16,速度提升巨大,精度损失极小
device=0
)
print(f"TensorRT引擎导出成功: {success}")
在实际工程中,使用TensorRT引擎进行推理,处理一帧货架图片的时间可以从几十毫秒缩短到几毫秒,这对于实时视频流分析至关重要。
6.3 编写一个简单的Flask API服务
为了让前端或者其他系统能调用我们的模型,咱们得写一个简单的Web API。这里用Flask演示,它轻量级,非常适合做这种微服务。
python
from flask import Flask, request, jsonify
import cv2
import numpy as np
from ultralytics import YOLO
import base64
app = Flask(__name__)
# 全局加载模型,避免每次请求都加载,浪费时间
# 实际生产中,这里加载的是 ONNX 或者 TensorRT 模型以获得更高性能
MODEL = YOLO('./runs/train/yolo11_retail_exp/weights/best.pt')
@app.route('/detect', methods=['POST'])
def detect():
"""
接收图片,返回检测结果
"""
if 'image' not in request.files:
return jsonify({'error': 'No image provided'}), 400
# 读取图片
file = request.files['image']
img_array = np.frombuffer(file.read(), np.uint8)
img = cv2.imdecode(img_array, cv2.IMREAD_COLOR)
# 推理
results = MODEL.predict(img, conf=0.25, iou=0.4, verbose=False)
# 解析结果
detections = []
for box in results[0].boxes:
x1, y1, x2, y2 = box.xyxy[0].cpu().numpy().tolist()
conf = box.conf[0].cpu().numpy().tolist()
detections.append({
'bbox': [x1, y1, x2, y2],
'confidence': conf,
'class': 'goods'
})
# 返回JSON数据
return jsonify({
'count': len(detections),
'detections': detections
})
if __name__ == '__main__':
# 启动服务,debug=True仅用于开发环境
app.run(host='0.0.0.0', port=5000, debug=False)
这个API怎么用呢?
前端或者摄像头抓拍程序,只需要发一个POST请求到 http://你的IP:5000/detect,带上图片文件,就能收到所有商品的坐标框。这对于开发库存管理系统来说,已经是万事俱备了。
七、总结与回顾
这次实战之旅,咱们从零售场景的痛点出发,一步步完成了YOLOv11商品检测模型的落地。
咱们做了这些事儿:
- 数据清洗:把SKU110K这个"硬骨头"啃下来,完成了VOC到YOLO格式的完美转换。
- 模型调优:针对密集货架场景,调整了输入分辨率、NMS阈值等关键参数,解决了漏检难题。
- 业务落地:不仅仅是画框,咱们还通过聚类算法实现了货架分层分析,把AI技术转化成了实实在在的业务指标(缺货率、层级饱和度)。
- 工程部署:从ONNX导出到Flask API搭建,打通了模型到应用的"最后一公里"。