IoT-For-Beginners 制造篇实战:用 IoT 设备拍照并通过 Custom Vision 分类水果质量
2026/9/17 8:51:32 网站建设 项目流程

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 引脚说明
CS24 (SPI_CS)SPI 片选
MOSI19 (SPI_MOSI)SPI 主出从入
MISO21 (SPI_MISO)SPI 主入从出
SCK23 (SPI_SCLK)SPI 时钟
GND6 (GND)地线 0V
VCC4 (5V)5V 电源
SDA3 (I2C1_SDA)I²C 数据线
SCL5 (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(0x260x41/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.hSeeed_FS.h,通过SD.begin(SDCARD_SS_PIN, SDCARD_SPI)初始化,然后实现saveToSDCard(byte *buffer, uint32_t length):以自增文件序号生成1.jpg2.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 picamera
import 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 = 0

CounterFit 的摄像头来源可设为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项目)中:

  1. 选择顶部的Performance标签页;
  2. Iterations列表中选择最新迭代;
  3. 点击该迭代的Publish按钮;
  4. 在弹出的Publish Model对话框中,将Prediction resource设置为上一课创建的fruit-quality-detector-prediction资源,名称保持Iteration2,点击Publish
  5. 发布完成后点击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。

  1. 同时复制对话框中的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.inilib_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 设备的实际部署环境——哪些因素会影响拍到的画面?

改进方法很直接:用设备实拍的照片重新训练模型。操作流程如下:

  1. 用 IoT 设备分别拍摄多张成熟与未成熟水果图片;
  2. 在 Custom Vision 门户的Predictions标签页用这些实拍图重新训练模型(可参考 第 1 课的重新训练说明);
  3. 如果实拍图与原始训练图差异非常大,可在Training Images标签页悬停选中原始图片后点击Delete全部删除;
  4. 训练新的迭代并按照上文步骤发布;
  5. 更新代码中的 endpoint URL,重新运行程序;
  6. 反复迭代,直到预测结果满意为止。

挑战:分辨率与光照对预测的影响有多大?

动手验证两个变量:

  • 修改设备代码中的图像分辨率,观察画质与预测结果是否变化;
  • 改变拍摄环境的光照条件,对比预测差异。

更进一步:如果为农场或工厂打造一款量产检测设备,你会如何保证它在各种环境下都能持续输出一致、稳定的预测结果?

课后作业:让设备对分类结果做出响应

本课作业见 assignment.md:设备已经拿到每个标签的预测值,接下来要让它真正行动起来——可以选择把数据发送到 IoT Hub 供其他系统处理,或控制 LED 之类的执行器(例如水果未成熟时点亮红灯),也可以两者结合:把数据发到 IoT Hub,由无服务器代码判断成熟度并向设备下发指令驱动执行器。评分标准关注的是:响应逻辑是否确实依赖预测值(优秀),还是只是机械地发送原始数据(合格),抑或完全无法让设备响应预测(待改进)。

延伸阅读与代码索引

除了本课分步指南外,还可关注以下仓库内容:

  • 完整拍照代码:code-camera/picode-camera/virtual-iot-devicecode-camera/wio-terminal(对应 4-manufacturing/lessons/2-check-fruit-from-device/code-camera);
  • 完整分类代码:code-classify/picode-classify/virtual-iot-devicecode-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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询