IoT-For-Beginners 制造篇实战:用 IoT 设备拍照并通过 Custom Vision 分类水果质量
【免费下载链接】IoT-For-Beginners12 Weeks, 24 Lessons, IoT for All!项目地址: https://gitcode.com/GitHub_Trending/io/IoT-For-Beginners
本课是「IoT-For-Beginners」制造(Manufacturing)路径的第 2 课,紧接第 1 课训练水果图像分类器之后,解决「如何让 IoT 设备真正用上已训练的模型」这一核心问题:为设备接入摄像头传感器、把上一课训练的 Custom Vision 模型发布为可调用的迭代版本,再从 Wio Terminal、Raspberry Pi 或虚拟设备端拍摄水果图片并上传云端完成成熟度分类。读完本课,你将掌握摄像头传感器的底层原理(CMOS/APS、SPI 数据通路)、三种典型 IoT 硬件平台的拍照代码、Custom Vision 迭代发布流程、Python SDK 与 REST API 两种云端调用方式,以及针对端侧图像差异的模型再训练方法。
课程定位:从「训练模型」走向「端侧推理」
上一课(1-train-fruit-detector)训练了一个能够区分成熟(ripe)与未成熟(unripe)水果的 Custom Vision 图像分类器。但模型本身并不产生价值——只有当它被真实设备调用、在真实产线上输出判断结果时才算闭环。本课沿着这条链路展开:
- 摄像头传感器:理解 IoT 摄像头如何工作,以及为什么它和手机摄像头存在巨大差异;
- 拍照:在 Wio Terminal、Raspberry Pi 和虚拟 IoT 设备上分别编写拍照代码;
- 发布模型:在 Custom Vision 门户中将迭代(iteration)发布为可被外部调用的预测端点;
- 云端分类:分别用 Python SDK(树莓派/虚拟设备)和 REST API(Wio Terminal)上传图片并解析分类结果;
- 模型改进:针对端侧图片与训练图片的差异,用真实设备照片重新训练迭代。
仓库中本课的完整代码位于 4-manufacturing/lessons/2-check-fruit-from-device 下的code-camera(纯拍照)与code-classify(拍照+分类)两个目录,分别对应 Wio Terminal、树莓派、虚拟设备三套硬件。
摄像头传感器:IoT 设备的「眼睛」是如何工作的
摄像头传感器,顾名思义,就是可以连接到 IoT 设备的摄像头。它们既能拍摄静态照片,也能录制流式视频;有的直接返回原始图像数据,有的则把数据压缩为 JPEG、PNG 等图像文件。相比大家熟悉的手机摄像头,面向 IoT 设备的摄像头通常体积更小、分辨率更低,但同样可以买到与高端智能手机一较高下的高分辨率型号,还支持可更换镜头、多摄像头阵列、红外热成像乃至紫外摄像头等特殊形态。
绝大多数摄像头传感器使用图像传感器(image sensor),其核心是每一个像素对应一个光电二极管(photodiode):
镜头把图像聚焦到图像传感器表面,成千上万(乃至数百万)个光电二极管各自记录落在其上的光强,并将其保存为像素数据。
几个需要记住的技术要点:
- 💁 镜头会使图像上下颠倒,摄像头传感器再把它翻转回正确方向。人眼也是如此——你看到的影像在眼球后部是倒置的,由大脑负责校正。
- 🎓 这类图像传感器学名Active-Pixel Sensor(APS),其中最流行的一种是互补金属氧化物半导体传感器,即常说的CMOS 传感器。
- 摄像头传感器是数字传感器,以数字数据形式发送图像信息,通常借助一个封装了通信协议的库来完成传输。由于图像数据量远大于温度传感器之类的单值读数,摄像头一般通过SPI等高速协议连接。
✅ 思考题:IoT 设备(尤其是单片机硬件)在图片大小上会面临哪些限制?请从存储、内存与网络带宽三个角度展开思考。
用 IoT 设备拍摄待分类图片
本课提供了三条硬件路径,仓库中对应的分步指南为 wio-terminal-camera.md、pi-camera.md 与 virtual-device-camera.md。
Wio Terminal + ArduCam:SPI/I²C 双总线接线与底层驱动
硬件选型:Wio Terminal 搭配ArduCam Mini 2MP Plus。这是一颗基于 OV2640 图像传感器、约 200 万像素的摄像头,通过 SPI 接口传输图像数据,并通过 I²C 配置传感器参数。ArduCam 没有 Grove 插座,需要直接用杜邦线连接 Wio Terminal 的 GPIO 引脚。
接线对应关系如下表(建议先贴好 Wio Terminal 自带的 GPIO 引脚贴纸以便对照):
| ArduCAM 引脚 | Wio Terminal 引脚 | 说明 |
|---|---|---|
| CS | 24 (SPI_CS) | SPI 片选 |
| MOSI | 19 (SPI_MOSI) | SPI 主出从入 |
| MISO | 21 (SPI_MISO) | SPI 主入从出 |
| SCK | 23 (SPI_SCLK) | SPI 时钟 |
| GND | 6 (GND) | 地线 0V |
| VCC | 4 (5V) | 5V 电源 |
| SDA | 3 (I2C1_SDA) | I²C 数据线 |
| SCL | 5 (I2C1_SCL) | I²C 时钟线 |
💁 注意:ArduCam 工作电压为 5V,与 Grove 传感器常见的 3V 不同,供电直接来自 USB-C 供电链路。
工程配置:在 PlatformIO 中新建名为fruit-quality-detector的 Wio Terminal 工程。ArduCam 库无法通过platformio.ini直接安装,需要从其 GitHub 仓库(ArduCAM/Arduino)克隆或下载 zip,并把整个ArduCAM文件夹复制到工程的lib目录(必须保证代码位于lib/ArduCam,不能只复制文件夹内容)。随后在platformio.ini末尾追加编译宏,让库只为当前硬件裁剪代码:
build_flags = -DARDUCAM_SHIELD_V2 -DOV2640_CAM其中ARDUCAM_SHIELD_V2告诉库摄像头位于 Arduino 扩展板(shield)上,OV2640_CAM则让库只保留 OV2640 型号的驱动代码,以尽量减小固件体积。
摄像头封装类:在src下新建camera.h,封装与 ArduCam 的底层通信。核心的init()方法依次完成:复位 CPLD(写入寄存器0x07)、校验 SPI 总线(ARDUCHIP_TEST1寄存器写入/读回0x55)、将 MCU 切到MCU2LCD_MODE、读取 OV2640 芯片 ID(0x26与0x41/0x42)验证传感器型号,最后设置 JPEG 格式与分辨率。拍照流程则拆成三段:startCapture()冲刷并清空 FIFO 后启动捕获;captureReady()轮询CAP_DONE_MASK标志位判断拍摄是否完成;readImageToBuffer()以 SPI 突发模式从摄像头 FIFO 读取 JPEG 字节流(以0xFF 0xD8起始、0xFF 0xD9结束定位 JPEG 头尾),存入动态分配的缓冲区。
在main.cpp中实例化摄像头并编写初始化函数:
#include "camera.h" Camera camera = Camera(JPEG, OV2640_640x480); void setupCamera() { pinMode(PIN_SPI_SS, OUTPUT); digitalWrite(PIN_SPI_SS, HIGH); Wire.begin(); SPI.begin(); if (!camera.init()) { Serial.println("Error setting up the camera!"); } }setupCamera先把 SPI 片选引脚置高(使 Wio Terminal 成为 SPI 主机),再启动 I²C 与 SPI 总线,最后调用camera.init()完成传感器配置与接线自检。在setup()末尾调用它;若串口输出Error setting up the camera!,应逐一核对 ArduCam 与 Wio Terminal 各引脚的接线。
按钮触发拍照:单片机上的代码是持续循环执行的,因此用 Wio Terminal 顶部的 C 键(最靠近电源开关的那颗)作为拍照触发源。将 C 键配置为INPUT_PULLUP模式(未按下输出高电平、按下输出低电平),然后在loop()中检测WIO_KEY_C拉低时调用buttonPressed(),并延时 2 秒防止长按重复触发:
void loop() { if (digitalRead(WIO_KEY_C) == LOW) { buttonPressed(); delay(2000); } delay(200); }在buttonPressed()中完成一次完整的「拍摄→读出」流程:
camera.startCapture(); while (!camera.captureReady()) delay(100); Serial.println("Image captured"); byte *buffer; uint32_t length; if (camera.readImageToBuffer(&buffer, length)) { Serial.print("Image read to buffer with length "); Serial.println(length); delete(buffer); }摄像头硬件的工作方式是异步的:先发送开始捕获的指令,摄像头在后台完成取景、JPEG 压缩并暂存在自身缓冲区,随后captureReady()轮询确认完成,最后readImageToBuffer把图像数据拷入本地字节数组。串口输出示例:
Connecting to WiFi.. Connected! Image captured Image read to buffer with length 9224 Image captured Image read to buffer with length 11272💁 不同图像的字节数不同:同一分辨率下 JPEG 文件大小取决于画面内容本身。
可选:用 microSD 卡验证图片。Wio Terminal 只支持最大 16GB 的 microSD 卡(需格式化为 FAT32/exFAT)。在main.cpp中引入SD/Seeed_SD.h与Seeed_FS.h,通过SD.begin(SDCARD_SS_PIN, SDCARD_SPI)初始化,然后实现saveToSDCard(byte *buffer, uint32_t length):以自增文件序号生成1.jpg、2.jpg等文件名,把缓冲区二进制数据写入SD.open(buff, FILE_WRITE)打开的文件。将saveToSDCard(buffer, length)调用放在delete(buffer)之前。之后即可弹出 SD 卡在电脑上查看照片。实际拍摄效果可参考 images/banana-arducam.jpg——注意白平衡通常需要拍几张才会自动校准,前几张可能偏色,必要时可在setup()里丢弃前几帧。
Raspberry Pi + PiCamera:MIPI-CSI 摄像头与 legacy 模式
硬件连接:树莓派使用的 Raspberry Pi Camera Module 通过排线连接。排线插入摄像头时蓝面朝外、金属引脚朝向镜头;将 Grove Base Hat 取下后,让排线穿过 Hat 的摄像头槽位(蓝面朝向 A0/A1 等模拟端口一侧),再插入树莓派主板上的 CSI 摄像头插座(蓝面朝向 USB/以太网口),最后装回 Grove Base Hat。
💁 该摄像头采用MIPI-CSI(Camera Serial Interface)协议,是移动行业处理器接口联盟定义的一种专用于传输图像的协议。
启用 legacy 摄像头模式:Raspberry Pi OS Bullseye 起默认的摄像头软件栈发生了变化,导致 PiCamera 库默认不可用(其继任者 PiCamera2 当时尚未就绪)。需要先进入 legacy 模式,该操作同时会启用默认被禁用的摄像头插座:
sudo raspi-config nonint do_legacy 0 sudo reboot重启后重新打开 VS Code(可在 Pi 本机或通过 Remote SSH 连接)。
拍照程序:在用户主目录新建fruit-quality-detector文件夹与app.py,安装依赖后编写:
pip3 install picameraimport io import time from picamera import PiCamera camera = PiCamera() camera.resolution = (640, 480) camera.rotation = 0 time.sleep(2) image = io.BytesIO() camera.capture(image, 'jpeg') image.seek(0) with open('image.jpg', 'wb') as image_file: image_file.write(image.read())要点解析:
- 分辨率设为 640×480。虽然 PiCamera 支持最高 3280×2464 的解析度,但图像分类器工作在 227×227 的小图上,无需采集和上传更大的图片;
camera.rotation = 0控制图像旋转角度。若排线从摄像头底部接入导致画面方向不对,可按需改为180(如 images/pi-camera-upside-down.png 所示悬挂场景)或其他角度;time.sleep(2)留给摄像头启动时间;BytesIO对象保存 JPEG 二进制数据,image.seek(0)把位置指针拨回开头——本课后面会直接把这段内存数据发送给分类器,这正是不直接存文件的原因。
运行后当前目录会生成image.jpg,可在 VS Code 资源管理器中点开检查是否需要调整旋转角。完整代码见 code-camera/pi/fruit-quality-detector/app.py。
虚拟 IoT 设备 + CounterFit:用文件或摄像头模拟拍摄
没有实体硬件时,可以用虚拟设备完成整条链路。在 CounterFit 中创建名为Picamera的 Camera 传感器,并安装一个模拟 PiCamera 部分接口的 shim 包:
pip install counterfit-shims-picamera随后在app.py中连接 CounterFit 并初始化「摄像头」:
from counterfit_connection import CounterFitConnection CounterFitConnection.init('127.0.0.1', 5000) import io from counterfit_shims_picamera import PiCamera camera = PiCamera() camera.resolution = (640, 480) camera.rotation = 0CounterFit 的摄像头来源可设为File(上传一张图片)或WebCam(调用电脑摄像头),选好后务必点击Set按钮生效。后续的capture与文件保存代码与树莓派版本完全一致。完整的虚拟设备工程见 code-camera/virtual-iot-device/fruit-quality-detector/app.py;关于 CounterFit Python 工程的创建方式可回看 1-getting-started 第 1 课的虚拟设备指南。
发布你的图像分类器:从「训练迭代」到「可调用端点」
模型在上一课训练完成后,必须先发布才能被外部应用调用。
模型迭代(Iteration)机制
训练模型时,门户的Performance标签页会记录迭代历史:首次训练生成Iteration 1,用预测图片改进后再次训练生成Iteration 2。每次训练都会产生一个新迭代,用于追踪不同数据集版本之间的差异。执行Quick Test时可通过下拉菜单切换迭代以横向对比效果。发布某一迭代后,设备端会继续使用该已发布版本,你可以并行地在新迭代上继续迭代训练,直到满意后再发布下一个版本。
发布迭代的操作步骤
在 Custom Vision 门户(fruit-quality-detector项目)中:
- 选择顶部的Performance标签页;
- 从Iterations列表中选择最新迭代;
- 点击该迭代的Publish按钮;
- 在弹出的Publish Model对话框中,将Prediction resource设置为上一课创建的
fruit-quality-detector-prediction资源,名称保持Iteration2,点击Publish; - 发布完成后点击Prediction URL按钮,对话框会给出调用预测 API 所需的全部信息。我们需要底部If you have an image file部分的 URL,形如:
https://<location>.api.cognitive.microsoft.com/customvision/v3.0/Prediction/<id>/classify/iterations/Iteration2/image其中<location>是创建 Custom Vision 资源时选择的位置,<id>是一串由字母和数字组成的长 ID。
- 同时复制对话框中的Prediction-Key值——这是调用模型的密钥:只有携带该密钥的应用才被允许使用模型,其余请求都会被拒绝。
✅ 思考题:每次发布新迭代都会有不同的名称。如果设备要切换到新的迭代版本,你会如何修改设备代码?
从 IoT 设备调用分类器
拿到连接信息后,就可以从设备侧发起预测请求了。仓库提供了两套实现:Python(面向树莓派/虚拟设备)与 C++ REST(面向 Wio Terminal)。
Python 方式:Custom Vision Prediction SDK
适用于树莓派和虚拟设备。安装官方 SDK:
pip3 install azure-cognitiveservices-vision-customvision在app.py顶部导入认证与预测客户端,并把上一节复制的 URL 与密钥填入:
from msrest.authentication import ApiKeyCredentials from azure.cognitiveservices.vision.customvision.prediction import CustomVisionPredictionClient prediction_url = '<prediction_url>' prediction_key = '<prediction key>'Prediction URL 对话框给出的 URL 是为直接调用 REST 端点设计的,而 Python SDK 需要把 URL 拆解成不同部分:
parts = prediction_url.split('/') endpoint = 'https://' + parts[2] project_id = parts[6] iteration_name = parts[9]即分别提取出https://<location>.api.cognitive.microsoft.com端点、项目 ID 与已发布迭代名称。接着创建预测客户端并发起分类:
prediction_credentials = ApiKeyCredentials(in_headers={"Prediction-key": prediction_key}) predictor = CustomVisionPredictionClient(endpoint, prediction_credentials) image.seek(0) results = predictor.classify_image(project_id, iteration_name, image) for prediction in results.predictions: print(f'{prediction.tag_name}:\t{prediction.probability * 100:.2f}%')classify_image之前先把图片指针回拨到开头;返回的概率是 0~1 之间的浮点数,0 表示 0% 匹配该标签、1 表示 100% 匹配。分类器会为所有训练用过的标签各返回一个概率,因此输出形如:
(.venv) ➜ fruit-quality-detector python app.py ripe: 56.84% unripe: 43.16%该结果也会同步出现在 Custom Vision 的Predictions标签页(示例见 images/custom-vision-banana-prediction.png)。完整代码见 code-classify/pi/fruit-quality-detector/app.py 与 code-classify/virtual-iot-device/fruit-quality-detector/app.py。
Wio Terminal 方式:HTTPS REST 调用与证书处理
Custom Vision 提供 REST API,可通过 HTTPS 从 Wio Terminal 直接调用。但 HTTPS 有个特殊之处:浏览器会自动获取服务器证书,单片机不会。客户端需要手动携带目标服务器的公钥证书来建立安全连接——这类证书只含公钥,无需保密,可以硬编码进源码甚至公开分享。
第一步:配置 SSL 客户端。在config.h中加入Microsoft Azure DigiCert Global Root G2根证书(完整 PEM 文本较长,此处以注释示意,完整内容见 code-classify/wio-terminal/fruit-quality-detector/src/config.h):
const char *CERTIFICATE = "-----BEGIN CERTIFICATE-----\r\n" "MIIF8zCCBNugAwIBAgIQAueRcfuAIek/4tmDg0xQwDANBgkqhkiG9w0BAQwFADBh\r\n" // ... 完整证书内容见仓库 config.h ... "-----END CERTIFICATE-----\r\n";如需自行核验该证书,可在 macOS/Linux 上执行(Windows 可用 WSL):
openssl s_client -showcerts -verify 5 -connect api.cognitive.microsoft.com:443输出会列出 DigiCert Global Root G2 证书。然后在main.cpp中引入#include <WiFiClientSecure.h>,声明WiFiClientSecure client;并在connectWiFi中调用client.setCACert(CERTIFICATE);把证书挂到安全客户端上。
第二步:编写分类函数。在platformio.ini的lib_deps中追加 JSON 解析库:
bblanchon/ArduinoJson @ 6.17.3在config.h中加入预测端点与密钥常量(同样参考仓库 config.h):
const char *PREDICTION_URL = "<PREDICTION_URL>"; const char *PREDICTION_KEY = "<PREDICTION_KEY>";随后在main.cpp中实现classifyImage,它接收拍照阶段读出的 JPEG 字节缓冲:
void classifyImage(byte *buffer, uint32_t length) { HTTPClient httpClient; httpClient.begin(client, PREDICTION_URL); httpClient.addHeader("Content-Type", "application/octet-stream"); httpClient.addHeader("Prediction-Key", PREDICTION_KEY); int httpResponseCode = httpClient.POST(buffer, length); if (httpResponseCode == 200) { String result = httpClient.getString(); DynamicJsonDocument doc(1024); deserializeJson(doc, result.c_str()); JsonObject obj = doc.as<JsonObject>(); JsonArray predictions = obj["predictions"].as<JsonArray>(); 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); } } httpClient.end(); }这段代码用HTTPClient建立到预测 URL 的连接(底层走已配置证书的WiFiClientSecure),发送两个请求头:Content-Type: application/octet-stream表明上传的是原始二进制数据,Prediction-Key传递预测密钥;然后 POST 上传 JPEG 字节数组,根据 HTTP 状态码判断结果——200 表示OK。响应是 JSON 文本,结构如下:
{ "id":"45d614d3-7d6f-47e9-8fa2-04f237366a16", "project":"135607e5-efac-4855-8afb-c93af3380531", "iteration":"04f1c1fa-11ec-4e59-bb23-4c7aca353665", "created":"2021-06-10T17:58:58.959Z", "predictions":[ { "probability":0.5582016, "tagId":"05a432ea-9718-4098-b14f-5f0688149d64", "tagName":"ripe" }, { "probability":0.44179836, "tagId":"bb091037-16e5-418e-a9ea-31c6a2920f17", "tagName":"unripe" } ] }关键的predictions数组为每个标签各返回一条记录(tagName与 0~1 的概率值),用 ArduinoJson 解码后逐条输出到串口。最后在buttonPressed()中调用classifyImage(buffer, length)(放在delete(buffer)之前;若无需 SD 卡存档,可直接替换保存代码并删除setupSDCard/saveToSDCard函数)。上传运行后对准水果按下 C 键,串口输出:
Connecting to WiFi.. Connected! Image captured Image read to buffer with length 8200 ripe: 56.84% unripe: 43.16%完整工程见 code-classify/wio-terminal/fruit-quality-detector,其中 platformio.ini 列出了全部依赖库与编译宏。
改进模型:训练数据与端侧图像之间存在鸿沟
设备端预测结果未必如预期精确——端侧相机拍摄的照片与训练时用的图片往往差异巨大,因为模型是在与预测数据不同的数据上训练的。例如用手机拍摄训练照片时,图像清晰度、锐度与色彩,都和 IoT 设备摄像头完全不同:
上图左为 Raspberry Pi 摄像头拍摄的香蕉,右为同一根香蕉、同一位置用 iPhone 拍摄。两者画质差异明显:iPhone 画面更锐利、色彩更亮、对比度更高。
✅ 思考题:除了画质,还有哪些因素会导致 IoT 设备拍出的照片预测错误?请想想 IoT 设备的实际部署环境——哪些因素会影响拍到的画面?
改进方法很直接:用设备实拍的照片重新训练模型。操作流程如下:
- 用 IoT 设备分别拍摄多张成熟与未成熟水果图片;
- 在 Custom Vision 门户的Predictions标签页用这些实拍图重新训练模型(可参考 第 1 课的重新训练说明);
- 如果实拍图与原始训练图差异非常大,可在Training Images标签页悬停选中原始图片后点击Delete全部删除;
- 训练新的迭代并按照上文步骤发布;
- 更新代码中的 endpoint URL,重新运行程序;
- 反复迭代,直到预测结果满意为止。
挑战:分辨率与光照对预测的影响有多大?
动手验证两个变量:
- 修改设备代码中的图像分辨率,观察画质与预测结果是否变化;
- 改变拍摄环境的光照条件,对比预测差异。
更进一步:如果为农场或工厂打造一款量产检测设备,你会如何保证它在各种环境下都能持续输出一致、稳定的预测结果?
课后作业:让设备对分类结果做出响应
本课作业见 assignment.md:设备已经拿到每个标签的预测值,接下来要让它真正行动起来——可以选择把数据发送到 IoT Hub 供其他系统处理,或控制 LED 之类的执行器(例如水果未成熟时点亮红灯),也可以两者结合:把数据发到 IoT Hub,由无服务器代码判断成熟度并向设备下发指令驱动执行器。评分标准关注的是:响应逻辑是否确实依赖预测值(优秀),还是只是机械地发送原始数据(合格),抑或完全无法让设备响应预测(待改进)。
延伸阅读与代码索引
除了本课分步指南外,还可关注以下仓库内容:
- 完整拍照代码:
code-camera/pi、code-camera/virtual-iot-device、code-camera/wio-terminal(对应 4-manufacturing/lessons/2-check-fruit-from-device/code-camera); - 完整分类代码:
code-classify/pi、code-classify/virtual-iot-device、code-classify/wio-terminal(对应 4-manufacturing/lessons/2-check-fruit-from-device/code-classify); - 第 1 课训练分类器的完整背景:1-train-fruit-detector;
- 本路径后续课将继续推进检测结果的执行与触发:3-run-fruit-detector-edge、4-trigger-fruit-detector。
💁 本课模型基于门户在线训练,依赖现成的训练图片。若真实场景中无法获得与设备摄像头一致的训练数据,还可以改用 Custom Vision 训练 API,直接用 IoT 设备拍摄的图片在线训练模型,从而绕开数据不匹配的问题(参见 Azure 文档中的 Custom Vision SDK 图像分类快速入门)。
【免费下载链接】IoT-For-Beginners12 Weeks, 24 Lessons, IoT for All!项目地址: https://gitcode.com/GitHub_Trending/io/IoT-For-Beginners
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考