IoT 库存盘点实战:从 IoT 设备调用 Custom Vision 目标检测器清点货架库存
【免费下载链接】IoT-For-Beginners12 Weeks, 24 Lessons, IoT for All!项目地址: https://gitcode.com/GitHub_Trending/io/IoT-For-Beginners
本文基于 IoT-For-Beginners 零售项目的第 2 课《从 IoT 设备检查库存》(5-retail 项目 Lesson 20)编写,讲解如何把上一课训练的
stock-detector目标检测模型部署发布,从 Wio Terminal / Raspberry Pi / 虚拟 IoT 设备上调用它,利用预测结果与边界框(Bounding Box)实现货架库存清点与异常货品识别,并介绍基于真实设备数据重新训练模型的完整闭环流程。
本课是零售项目的收官一课。读完本篇文章,你将掌握:如何在 Custom Vision 门户发布模型迭代并获取预测 URL 与密钥;如何把图像分类代码改造为目标检测代码(含置信度阈值过滤);如何理解并计算 0–1 归一化的边界框坐标;以及如何通过边界框重叠检测剔除重复预测、准确统计货架商品数量,并将低库存告警接入 IoT 服务。
库存盘点的业务场景:数得准,还要找得对
目标检测器在零售场景中最直接的价值就是库存检查(Stock Checking)——既能清点库存数量,也能确认货品是否摆放正确。带有摄像头的 IoT 设备可以部署在门店各处持续监控库存,优先覆盖“热点区域”,例如存放少量高价值商品的区域,这些区域缺货时及时补货至关重要。
一个典型例子:摄像头对准一组最多可放 8 罐番茄酱的货架,如果目标检测器只检测到 7 罐,说明有 1 罐缺失,需要补货。
上图中,检测器在可容纳 8 罐的货架上检测出 7 罐番茄酱。IoT 设备不仅能发送“需要补货”的通知,还能给出缺失商品的大致位置——如果你使用机器人补货,这个位置信息至关重要。
💁 实际上,补货策略不能一概而论。对于热门商品只缺 1 罐可能无需立即补货。你需要根据商品属性、顾客流量等条件构建一个“何时补货”的判定算法。
除了缺货,货架上还可能存在错误的库存。这可能是补货时的人工失误,也可能是顾客改变主意后随手把商品放回第一个空位。对于罐头这类耐储存商品,这只是小麻烦;但对于冷冻或冷藏商品,一旦离开冷柜就无法确定脱离低温环境的时间,商品可能因此不能再售卖。
目标检测可以及时发现这类“不速之客”,并通知人类员工或机器人尽快把商品归位。
✅ 思考:除了库存盘点,还有哪些场景可以把目标检测与机器人结合起来?
在 Custom Vision 门户发布模型迭代
上一课训练好的目标检测器,需要通过 Custom Vision 门户**发布(Publish)**后,才能从 IoT 设备调用。发布操作会为某一迭代生成一个公开的预测 API 端点。
任务:发布一版目标检测器
打开 CustomVision.ai 门户并登录,打开你的
stock-detector项目。选择顶部选项中的Performance(性能)标签页。
从侧边Iterations(迭代)列表中选择最新一版迭代。
点击该迭代的Publish(发布)按钮。
在弹出的Publish Model对话框中,将Prediction resource(预测资源)设置为上一课创建的
stock-detector-prediction资源,名称保持Iteration2,点击Publish按钮。发布完成后,点击Prediction URL按钮,即可看到预测 API 的详细信息。你需要使用下方标注If you have an image file(如果你有图像文件)区域中的信息来从 IoT 设备调用模型。复制其中显示的 URL,格式类似:
https://<location>.api.cognitive.microsoft.com/customvision/v3.0/Prediction/<id>/detect/iterations/Iteration2/image其中
<location>是你创建自定义视觉资源时使用的区域,<id>是一长串由字母和数字组成的 ID。同时复制Prediction-Key(预测密钥)的值。这是调用模型时必须传递的安全密钥:只有携带该密钥的应用程序才被允许使用模型,其他应用都会被拒绝。
✅ 思考:当发布新一版迭代时,迭代名会不同。你认为如何让 IoT 设备切换到新的迭代?
⚠️ 本课是本项目的最后一课。完成本课及作业后,记得清理云服务资源;但请先完成作业,因为作业需要使用这些服务。清理步骤参考 clean-up.md(仓库根目录)。
从 IoT 设备调用目标检测器
模型发布后即可从 IoT 设备调用。整体思路是:复用上一课(制造项目)中图像分类项目的绝大部分代码,把“分类”调用改为“检测”调用,再针对检测结果做处理。
本课提供了两条硬件路线,均包含“先检测”和“再计数”两个阶段:
- Arduino - Wio Terminal:参考 wio-terminal-object-detector.md 与 wio-terminal-count-stock.md;
- 树莓派 / 虚拟设备:参考 single-board-computer-object-detector.md 与 single-board-computer-count-stock.md。
复用图像分类项目
- Wio Terminal:先按制造项目第 2 课 wio-terminal-camera.md 的步骤连接 ArduCam 摄像头(可用双面胶或把线缆挂在盒子上固定摄像头位置),然后用 PlatformIO 新建名为
stock-counter的项目;再从制造项目第 2 课 README.md 中“用 IoT 设备拍摄图像”与“从 IoT 设备分类图像”两个任务中复制代码,其中大部分会被复用到目标检测。 - 树莓派 / 虚拟设备:新建
stock-counter文件夹(虚拟设备需配置虚拟环境);树莓派需安装 PiCamera 并固定好位置,虚拟设备需安装 CounterFit 与 CounterFit PyCamera shim,准备模型未见过的静态图片或调整摄像头对准库存。
把分类代码改造成目标检测代码
分类与检测的主要区别在于:调用的 URL / SDK 方法不同,以及返回结果不同。图像分类每个标签只返回一个结果,而目标检测会返回多个结果(同一个标签可能有多处目标),因此必须用置信度阈值过滤掉低概率的检测框。
Python(树莓派 / 虚拟设备)
删除原来的三行分类代码:
results = predictor.classify_image(project_id, iteration_name, image) for prediction in results.predictions: print(f'{prediction.tag_name}:\t{prediction.probability * 100:.2f}%')替换为检测代码:
results = predictor.detect_image(project_id, iteration_name, image) threshold = 0.3 predictions = list(prediction for prediction in results.predictions if prediction.probability > threshold) for prediction in predictions: print(f'{prediction.tag_name}:\t{prediction.probability * 100:.2f}%')这段代码调用predictor.detect_image运行目标检测,然后收集所有概率高于阈值的预测并打印到控制台。运行后,摄像头会拍摄一张图片并发送给检测器:
pi@raspberrypi:~/stock-counter $ python3 app.py tomato paste: 34.13% tomato paste: 33.95% tomato paste: 35.05% tomato paste: 32.80%💁 可能需要根据你的图片情况调整
threshold(阈值)的取值。
完整代码见仓库中的 code-detect/pi/stock-counter/app.py 与 code-detect/virtual-iot-device/stock-counter/app.py。
C++(Wio Terminal)
在
main.cpp顶部添加#include <vector>;把
classifyImage函数重命名为detectStock(函数名与buttonPressed中的调用处都要改);在
detectStock函数上方声明阈值常量:const float threshold = 0.3f;声明一个处理预测结果的函数,把每个预测打印到串口监视器:
void processPredictions(std::vector<JsonVariant> &predictions) { for(JsonVariant prediction : predictions) { String tag = prediction["tagName"].as<String>(); float probability = prediction["probability"].as<float>(); char buff[32]; sprintf(buff, "%s:\t%.2f%%", tag.c_str(), probability * 100.0); Serial.println(buff); } }在
detectStock函数中,把遍历预测的for循环内容替换为“按阈值过滤”的逻辑:std::vector<JsonVariant> passed_predictions; for(JsonVariant prediction : predictions) { float probability = prediction["probability"].as<float>(); if (probability > threshold) { passed_predictions.push_back(prediction); } } processPredictions(passed_predictions);这段代码遍历所有预测,将概率高于阈值的预测加入列表并交给
processPredictions处理。上传运行代码,把摄像头对准货架上的商品并按下 C 按钮,串口监视器会输出:
Connecting to WiFi.. Connected! Image captured Image read to buffer with length 17416 tomato paste: 35.84% tomato paste: 35.87% tomato paste: 34.11% tomato paste: 35.16%
在仓库源码 code-detect/wio-terminal/stock-counter/src/main.cpp 中,可以看到完整的调用链:buttonPressed通过camera.startCapture()拍摄、camera.readImageToBuffer读取 JPEG 字节流,然后detectStock使用HTTPClient以application/octet-stream的Content-Type和Prediction-Key请求头向PREDICTION_URL(配置在 config.h)发起POST,返回的 JSON 经deserializeJson解析后取出predictions数组,再按阈值过滤、打印。
边界框(Bounding Box):预测结果的空间信息
调用目标检测器时,不仅会得到检测对象的标签(tag)和概率(probability),还会得到每个对象的边界框(Bounding Box)——它定义了检测器在图像中检测到该对象的位置区域。
💁 边界框就是框住被检测对象区域的方框,用于界定对象的边界。
在 Custom Vision 的Predictions标签页中,提交预测的图片上会直接绘制出边界框。
如上图,检测到了 4 罐番茄酱,每个检测对象都被叠加了红色方框,指示其边界框位置。
边界框的 4 个坐标值
边界框由 4 个值定义:top(上)、left(左)、height(高)、width(宽)。这些值都归一化到0–1之间,表示相对于图像尺寸的百分比。坐标系原点(0,0)位于图像左上角,因此top是距顶部的距离,边界框的底部 =top + height。
以上图为例:图片宽 600 像素、高 800 像素。边界框从向下 320 像素处开始,top = 0.4(800 × 0.4 = 320);从左侧 240 像素处开始,left = 0.4(600 × 0.4 = 240);框高 240 像素,height = 0.3(800 × 0.3 = 240);框宽 120 像素,width = 0.2(600 × 0.2 = 120)。
| 坐标值 | 数值 |
|---|---|
| Top | 0.4 |
| Left | 0.4 |
| Height | 0.3 |
| Width | 0.2 |
使用 0–1 的百分比值意味着:无论图像被缩放到多大,边界框都从横向和纵向的 0.4 处开始,高度占 0.3、宽度占 0.2。
用边界框 + 概率评估检测质量
边界框可以和概率结合,用来评估一次检测的准确性。例如检测器可能对同一个对象给出多个重叠的框——比如一个框被检测在另一个框内部。你的代码可以分析边界框,识别这种“不可能”的情况,并忽略与其他对象显著重叠的检测结果。
上例中,一个边界框以 78.3% 的概率框住了一罐番茄酱;第二个边界框略小,位于第一个框内部,概率为 64.3%。代码可以检查边界框、发现它们完全重叠,然后忽略概率较低的那个,因为一个罐子不可能存在于另一个罐子内部。
✅ 思考:你能想到某种“一个对象被检测在另一个对象内部”是合理的情形吗?
用 IoT 设备采集的数据重新训练模型
与图像分类器一样,你可以使用 IoT 设备采集的真实数据重新训练模型。真实场景数据能确保模型在设备端使用时表现良好。
与图像分类不同,目标检测的再训练不能只是给图片打标签,而必须逐一检查模型预测出的每个边界框:框错了对象就删除,框的位置不对就调整。
任务:重新训练模型
- 确保已用 IoT 设备采集了一批多样化的图片。
- 在Predictions标签页选择一张图片,会看到红色方框标出检测对象的边界框。
- 逐个处理每个边界框:先选中它,会弹出显示标签的窗口;必要时用框四角的控制点调整大小;如果标签错误,用X按钮移除并添加正确标签;如果边界框里没有对象,用垃圾桶按钮删除。
- 完成后关闭编辑器,该图片会从Predictions标签页移到Training Images标签页。对所有预测重复该过程。
- 点击Train按钮重新训练模型。训练完成后发布新迭代,并更新 IoT 设备,使其使用新迭代的 URL。
- 重新部署代码并测试 IoT 设备。
清点库存:结合对象数量与边界框消除重叠
仅凭检测框的数量统计库存并不准确——同一个对象可能被重复检测。正确做法是把概率阈值过滤后的边界框两两比较,剔除重叠超过阈值的重复框,再统计剩余框的数量。
边界框去重原理
如果两个框的重叠面积超过“较小框面积”的一定百分比,就认为它们框的是同一个对象,应保留概率更高(或更靠前)的那个。注意:重叠阈值是百分比,必须乘以较小的那个边界框的面积,而不是整张图的面积。
在 Wio Terminal 的 C++ 实现(code-count/wio-terminal/stock-counter/src/main.cpp)中,整套去重逻辑如下:
定义重叠阈值(20% 表示允许 20% 的重叠):
const float overlap_threshold = 0.20f;定义
Point、Rect结构体,并用area计算矩形面积、用overlappingArea计算两矩形重叠面积(不相交时返回 0):struct Point { float x, y; }; struct Rect { Point topLeft, bottomRight; }; float area(Rect rect) { return abs(rect.bottomRight.x - rect.topLeft.x) * abs(rect.bottomRight.y - rect.topLeft.y); } float overlappingArea(Rect rect1, Rect rect2) { float left = max(rect1.topLeft.x, rect2.topLeft.x); float right = min(rect1.bottomRight.x, rect2.bottomRight.x); float top = max(rect1.topLeft.y, rect2.topLeft.y); float bottom = min(rect1.bottomRight.y, rect2.bottomRight.y); if ( right > left && bottom > top ) { return (right-left)*(bottom-top); } return 0.0f; }定义
rectFromBoundingBox,把预测中的boundingBox(left/top/width/height)转换为Rect:右侧 = left + width,底部 = top + height。Rect rectFromBoundingBox(JsonVariant prediction) { JsonObject bounding_box = prediction["boundingBox"].as<JsonObject>(); float left = bounding_box["left"].as<float>(); float top = bounding_box["top"].as<float>(); float width = bounding_box["width"].as<float>(); float height = bounding_box["height"].as<float>(); Point topLeft = {left, top}; Point bottomRight = {left + width, top + height}; return {topLeft, bottomRight}; }在
processPredictions中两两比较所有预测:内层循环从i + 1开始,避免重复比较(1 与 2 比过后,2 只需与 3、4…比较);重叠面积超过overlap_threshold × 较小框面积时,当前框标记为不通过;全部通过则加入passed_predictions:std::vector<JsonVariant> passed_predictions; for (int i = 0; i < predictions.size(); ++i) { Rect prediction_1_rect = rectFromBoundingBox(predictions[i]); float prediction_1_area = area(prediction_1_rect); bool passed = true; for (int j = i + 1; j < predictions.size(); ++j) { Rect prediction_2_rect = rectFromBoundingBox(predictions[j]); float prediction_2_area = area(prediction_2_rect); float overlap = overlappingArea(prediction_1_rect, prediction_2_rect); float smallest_area = min(prediction_1_area, prediction_2_area); if (overlap > (overlap_threshold * smallest_area)) { passed = false; break; } } if (passed) { passed_predictions.push_back(predictions[i]); } }打印通过检查的预测详情与统计数量:
for(JsonVariant prediction : passed_predictions) { String boundingBox = prediction["boundingBox"].as<String>(); String tag = prediction["tagName"].as<String>(); float probability = prediction["probability"].as<float>(); char buff[32]; sprintf(buff, "%s:\t%.2f%%\t%s", tag.c_str(), probability * 100.0, boundingBox.c_str()); Serial.println(buff); } Serial.print("Counted "); Serial.print(passed_predictions.size()); Serial.println(" stock items.");
运行后串口输出示例:
Connecting to WiFi.. Connected! Image captured Image read to buffer with length 17416 tomato paste: 35.84% {"left":0.395631,"top":0.215897,"width":0.180768,"height":0.359364} tomato paste: 35.87% {"left":0.378554,"top":0.583012,"width":0.14824,"height":0.359382} tomato paste: 34.11% {"left":0.699024,"top":0.592617,"width":0.124411,"height":0.350456} tomato paste: 35.16% {"left":0.513006,"top":0.647853,"width":0.187472,"height":0.325817} Counted 4 stock items.💁 这种去重方式非常朴素:只移除重叠对中靠前的一个。生产环境应加入更复杂的逻辑,例如考虑多个对象之间的重叠,或某个框被另一个框包含的情况。
最终得到的库存数量可以上报给 IoT 服务,在库存过低时触发告警。
Python 版实现:打印与绘制边界框
树莓派 / 虚拟设备版本除了打印边界框,还能把边界框直接画到磁盘上的image.jpg里,方便调试:
把
for循环中的print改为同时输出边界框:print(f'{prediction.tag_name}:\t{prediction.probability * 100:.2f}%\t{prediction.bounding_box}')输出示例(left/top/width/height 均为 0–1):
pi@raspberrypi:~/stock-counter $ python3 app.py tomato paste: 33.42% {'additional_properties': {}, 'left': 0.3455171, 'top': 0.09916268, 'width': 0.14175442, 'height': 0.29405564} tomato paste: 34.41% {'additional_properties': {}, 'left': 0.48283678, 'top': 0.10242918, 'width': 0.11782813, 'height': 0.27467814}安装 Pillow 用于绘图(虚拟设备请在虚拟环境中执行):
pip3 install pillow在
app.py顶部导入:from PIL import Image, ImageDraw, ImageColor在文件末尾追加绘制代码:把 0–1 的框坐标乘以图像宽高换算为像素坐标(如 left 为 0.5、图宽 600 像素,则换算为 300),用红色线条画框并保存覆盖原图:
with Image.open('image.jpg') as im: draw = ImageDraw.Draw(im) for prediction in predictions: scale_left = prediction.bounding_box.left scale_top = prediction.bounding_box.top scale_right = prediction.bounding_box.left + prediction.bounding_box.width scale_bottom = prediction.bounding_box.top + prediction.bounding_box.height left = scale_left * im.width top = scale_top * im.height right = scale_right * im.width bottom = scale_bottom * im.height draw.rectangle([left, top, right, bottom], outline=ImageColor.getrgb('red'), width=2) im.save('image.jpg')安装 Shapely 计算多边形交集(树莓派需先安装依赖
sudo apt install libgeos-dev,再执行):pip3 install shapely导入
from shapely.geometry import Polygon,设置overlap_threshold = 0.20,并定义把边界框转换为 Shapely 多边形的函数:def create_polygon(prediction): scale_left = prediction.bounding_box.left scale_top = prediction.bounding_box.top scale_right = prediction.bounding_box.left + prediction.bounding_box.width scale_bottom = prediction.bounding_box.top + prediction.bounding_box.height return Polygon([(scale_left, scale_top), (scale_right, scale_top), (scale_right, scale_bottom), (scale_left, scale_bottom)])两两比较所有预测,用
Polygon.intersection计算重叠面积;超过阈值就标记删除。由于不能在遍历列表时删除元素,先把待删除项收集到to_delete,最后统一移除:to_delete = [] for i in range(0, len(predictions)): polygon_1 = create_polygon(predictions[i]) for j in range(i+1, len(predictions)): polygon_2 = create_polygon(predictions[j]) overlap = polygon_1.intersection(polygon_2).area smallest_area = min(polygon_1.area, polygon_2.area) if overlap > (overlap_threshold * smallest_area): to_delete.append(predictions[i]) break for d in to_delete: predictions.remove(d) print(f'Counted {len(predictions)} stock items')这段代码应放在绘制边界框之前,这样生成的图片上只会显示去重后的预测框。
上图中 4 个边界框只有少量重叠。如果重叠明显更大,很可能框的是同一个对象——这正是需要按重叠阈值剔除的原因。完整 Python 实现见 code-count/pi/stock-counter/app.py 与 code-count/virtual-iot-device/stock-counter/app.py。
挑战:检测错误库存
在完成基础库存计数后,可以进一步挑战:
- 检测错误库存:用多个对象训练模型,然后改造应用——当检测到“不该出现的货品”时发出告警。
- 侧向摆放检测:更进一步,检测同一货架并排摆放的库存,通过给边界框位置设定限制范围,判断是否有商品被放错位置。
作业:把目标检测器部署到边缘
上一节课你把图像分类器部署到了边缘设备。本次作业要求对目标检测器做同样的事:将其导出为压缩模型(compact model)并在边缘运行,让 IoT 设备调用边缘版本。评分标准:
| 标准 | 优秀 | 合格 | 待改进 |
|---|---|---|---|
| 将目标检测器部署到边缘 | 能够使用正确的压缩域,导出目标检测器并在边缘运行 | 能够使用正确的压缩域并导出目标检测器,但无法在边缘运行 | 无法使用正确的压缩域,无法导出目标检测器,也无法在边缘运行 |
作业细节见 assignment.md。
小结
本课完成了零售库存检测的最后一环:在 Custom Vision 门户发布Iteration2迭代,把classify_image升级为detect_image(或把classifyImage改造为detectStock),通过 0–1 归一化的边界框坐标理解检测结果的空间语义,再用重叠面积阈值过滤重复框,最终在 Wio Terminal、树莓派和虚拟设备三种平台上实现货架库存的准确清点。结合上一课的模型训练与本课的设备端调用,你已经具备搭建“摄像头 + 边缘设备 + Custom Vision 云端预测”零售库存监控方案的完整能力。
【免费下载链接】IoT-For-Beginners12 Weeks, 24 Lessons, IoT for All!项目地址: https://gitcode.com/GitHub_Trending/io/IoT-For-Beginners
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考