Wio Terminal 调用 Custom Vision 图像分类 REST API 实战:HTTPS 证书配置与 ArduinoJson 响应解析
2026/9/14 16:16:28 网站建设 项目流程

Wio Terminal 调用 Custom Vision 图像分类 REST API 实战:HTTPS 证书配置与 ArduinoJson 响应解析

【免费下载链接】IoT-For-Beginners12 Weeks, 24 Lessons, IoT for All!项目地址: https://gitcode.com/GitHub_Trending/io/IoT-For-Beginners

本篇技术指南基于 IoT-For-Beginners 项目中「检查水果质量」课程(4-manufacturing/lessons/2-check-fruit-from-device)的 Wio Terminal 实战文档,完整讲解如何把 Wio Terminal 摄像头拍摄的 JPEG 图像通过 HTTPS 安全连接发送到 Azure Custom Vision 图像分类服务,并在串口监视器中打印每个分类标签(如 ripe / unripe)的置信度。读完本文,你将掌握微控制器上手动配置 TLS 根证书、使用 HTTPClient 发送二进制 POST 请求、以及用 ArduinoJson 解析预测结果 JSON 的完整可运行方案。

场景背景:从摄像头到云端分类的完整链路

在本课程中,fruit-quality-detector应用运行在 Wio Terminal 上,通过 ArduCam Mini 2MP Plus(OV2640 图像传感器,SPI 通信、I²C 配置)采集图像。按下 Wio Terminal 顶部的 C 按钮后,程序从摄像头读取 JPEG 字节流到内存缓冲区,再把缓冲区作为请求体发送给上一课训练并发布的 Custom Vision 图像分类模型,最终把预测结果输出到串口。

从源码结构看,整条链路由三个环节组成(对应本课程的三个分步文档):

  1. 图像采集:wio-terminal-camera.md 中实现的Camera类(见 camera.h),通过 SPI 总线读取 OV2640 传感器 FIFO 中的 JPEG 数据;
  2. 模型发布:在 Custom Vision 门户的 Performance 页签把某次迭代(iteration)发布为可被外部调用的预测端点,获得预测 URL 与 Prediction-Key;
  3. 图像分类:本文主体,classifyImage函数把图像缓冲区通过 HTTPS 上传到预测端点并解析返回的 JSON。

fruit-quality-detector的完整工程位于 code-classify/wio-terminal,其中 main.cpp 的classifyImage函数即本文核心。

为什么微控制器需要手动配置 HTTPS 证书

Custom Vision 的预测服务通过 REST API 对外提供,且必须走 HTTPS(安全的 HTTP 连接)。HTTPS 建立会话时,客户端需要先从服务器请求公钥证书,并用该证书加密后续通信流量。

浏览器会自动完成「下载证书 → 校验 → 加密」这一过程,但微控制器(如 Wio Terminal 的 SAMD51 内核)并不会。开发者必须手动从服务端获取证书、把它硬编码到固件中,并告知网络客户端使用该证书建立安全连接。

需要说明的是:这类证书只包含公钥,不属于机密信息,可以直接写在源码里,甚至公开分享在 GitHub 等公开仓库中也无需担心安全问题。这与后面提到的PREDICTION_KEY(机密,必须保密)有本质区别。

对于 Custom Vision,其预测端点域名api.cognitive.microsoft.com使用 Azure 服务常用的Microsoft Azure DigiCert Global Root G2根证书。如果你在 macOS 或 Linux 下想自行确认这一点(Windows 用户可在 WSL 中执行),可运行:

openssl s_client -showcerts -verify 5 -connect api.cognitive.microsoft.com:443

输出中会列出 DigiCert Global Root G2 证书。

任务一:搭建 SSL 客户端(硬编码根证书)

在 config.h 中声明证书常量

打开fruit-quality-detector工程的config.h头文件,添加如下常量(完整内容同样见仓库中的 config.h):

const char *CERTIFICATE = "-----BEGIN CERTIFICATE-----\r\n" "MIIF8zCCBNugAwIBAgIQAueRcfuAIek/4tmDg0xQwDANBgkqhkiG9w0BAQwFADBh\r\n" "MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3\r\n" "d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBH\r\n" "MjAeFw0yMDA3MjkxMjMwMDBaFw0yNDA2MjcyMzU5NTlaMFkxCzAJBgNVBAYTAlVT\r\n" "MR4wHAYDVQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xKjAoBgNVBAMTIU1pY3Jv\r\n" "c29mdCBBenVyZSBUTFMgSXNzdWluZyBDQSAwNjCCAiIwDQYJKoZIhvcNAQEBBQAD\r\n" "ggIPADCCAgoCggIBALVGARl56bx3KBUSGuPc4H5uoNFkFH4e7pvTCxRi4j/+z+Xb\r\n" "wjEz+5CipDOqjx9/jWjskL5dk7PaQkzItidsAAnDCW1leZBOIi68Lff1bjTeZgMY\r\n" "iwdRd3Y39b/lcGpiuP2d23W95YHkMMT8IlWosYIX0f4kYb62rphyfnAjYb/4Od99\r\n" "ThnhlAxGtfvSbXcBVIKCYfZgqRvV+5lReUnd1aNjRYVzPOoifgSx2fRyy1+pO1Uz\r\n" "aMMNnIOE71bVYW0A1hr19w7kOb0KkJXoALTDDj1ukUEDqQuBfBxReL5mXiu1O7WG\r\n" "0vltg0VZ/SZzctBsdBlx1BkmWYBW261KZgBivrql5ELTKKd8qgtHcLQA5fl6JB0Q\r\n" "gs5XDaWehN86Gps5JW8ArjGtjcWAIP+X8CQaWfaCnuRm6Bk/03PQWhgdi84qwA0s\r\n" "sRfFJwHUPTNSnE8EiGVk2frt0u8PG1pwSQsFuNJfcYIHEv1vOzP7uEOuDydsmCjh\r\n" "lxuoK2n5/2aVR3BMTu+p4+gl8alXoBycyLmj3J/PUgqD8SL5fTCUegGsdia/Sa60\r\n" "N2oV7vQ17wjMN+LXa2rjj/b4ZlZgXVojDmAjDwIRdDUujQu0RVsJqFLMzSIHpp2C\r\n" "Zp7mIoLrySay2YYBu7SiNwL95X6He2kS8eefBBHjzwW/9FxGqry57i71c2cDAgMB\r\n" "AAGjggGtMIIBqTAdBgNVHQ4EFgQU1cFnOsKjnfR3UltZEjgp5lVou6UwHwYDVR0j\r\n" "BBgwFoAUTiJUIBiV5uNu5g/6+rkS7QYXjzkwDgYDVR0PAQH/BAQDAgGGMB0GA1Ud\r\n" "JQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjASBgNVHRMBAf8ECDAGAQH/AgEAMHYG\r\n" "CCsGAQUFBwEBBGowaDAkBggrBgEFBQcwAYYYaHR0cDovL29jc3AuZGlnaWNlcnQu\r\n" "Y29tMEAGCCsGAQUFBzAChjRodHRwOi8vY2FjZXJ0cy5kaWdpY2VydC5jb20vRGln\r\n" "aUNlcnRHbG9iYWxSb290RzIuY3J0MHsGA1UdHwR0MHIwN6A1oDOGMWh0dHA6Ly9j\r\n" "cmwzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydEdsb2JhbFJvb3RHMi5jcmwwN6A1oDOG\r\n" "MWh0dHA6Ly9jcmw0LmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydEdsb2JhbFJvb3RHMi5j\r\n" "cmwwHQYDVR0gBBYwFDAIBgZngQwBAgEwCAYGZ4EMAQICMBAGCSsGAQQBgjcVAQQD\r\n" "AgEAMA0GCSqGSIb3DQEBDAUAA4IBAQB2oWc93fB8esci/8esixj++N22meiGDjgF\r\n" "+rA2LUK5IOQOgcUSTGKSqF9lYfAxPjrqPjDCUPHCURv+26ad5P/BYtXtbmtxJWu+\r\n" "cS5BhMDPPeG3oPZwXRHBJFAkY4O4AF7RIAAUW6EzDflUoDHKv83zOiPfYGcpHc9s\r\n" "kxAInCedk7QSgXvMARjjOqdakor21DTmNIUotxo8kHv5hwRlGhBJwps6fEVi1Bt0\r\n" "trpM/3wYxlr473WSPUFZPgP1j519kLpWOJ8z09wxay+Br29irPcBYv0GMXlHqThy\r\n" "8y4m/HyTQeI2IMvMrQnwqPpY+rLIXyviI2vLoI+4xKE4Rn38ZZ8m\r\n" "-----END CERTIFICATE-----\r\n";

这份 PEM 格式的根证书不会频繁更换,因此可以放心长期硬编码在固件中;一旦 Azure 更换根证书,只需替换该常量并重新烧录即可。

在 main.cpp 中创建 WiFiClientSecure 实例

接着在main.cpp顶部加入 HTTPS 客户端的头文件(仓库 main.cpp 第 8 行):

#include <WiFiClientSecure.h>

在 include 语句下方,声明一个全局的WiFiClientSecure实例:

WiFiClientSecure client;

这个类封装了与 HTTPS 端点通信的能力,负责在 TCP 之上建立 TLS 安全会话。最后在connectWiFi方法(即 WiFi 连接成功后)把证书交给客户端:

client.setCACert(CERTIFICATE);

setCACert传入的是 CA 根证书,WiFiClientSecure 会用它对服务端证书链进行验证,并利用其中的公钥加密后续发送的请求。

任务二:引入 ArduinoJson 并配置预测端点到 config.h

添加依赖

Custom Vision 预测接口返回的是 JSON 文本,微控制器上需要第三方库来解析。在工程的platformio.inilib_deps列表中追加一行(仓库 platformio.ini 第 22 行):

bblanchon/ArduinoJson @ 6.17.3

完整的lib_deps如下,其中前几项是 Wio Terminal 连接 WiFi、文件系统与 mbedtls 加密所必需的,ArduinoJson 是新增的 JSON 解析库:

lib_deps = seeed-studio/Seeed Arduino rpcWiFi @ 1.0.5 seeed-studio/Seeed Arduino FS @ 2.1.1 seeed-studio/Seeed Arduino SFUD @ 2.0.2 seeed-studio/Seeed Arduino rpcUnified @ 2.1.3 seeed-studio/Seeed_Arduino_mbedtls @ 3.0.1 seeed-studio/Seeed Arduino RTC @ 2.0.0 bblanchon/ArduinoJson @ 6.17.3

从 Custom Vision 门户获取预测 URL 与密钥

在 Custom Vision 门户(customvision.ai)打开fruit-quality-detector项目,进入Performance页签,选中最新一次迭代并点击Publish,发布成功后点击Prediction URL按钮,即可看到「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——这是调用模型时必须携带的密钥,只有携带正确密钥的应用才被允许调用,其他请求都会被拒绝,因此请妥善保管。

config.h中把这两个值声明为常量(仓库 config.h 第 11-12 行):

const char *PREDICTION_URL = "<PREDICTION_URL>"; const char *PREDICTION_KEY = "<PREDICTION_KEY>";

<PREDICTION_URL>替换为门户中复制的预测 URL,将<PREDICTION_KEY>替换为预测密钥。注意:如果后续发布了新迭代(例如Iteration3),预测 URL 中的迭代名会变化,需要同步更新该常量。

任务三:实现 classifyImage 函数

main.cpp中为 ArduinoJson 添加 include:

#include <ArduinoJSON.h>

然后在buttonPressed函数上方添加如下核心函数(仓库 main.cpp 第 60-91 行):

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(); }

这段代码的执行逻辑可以拆解为五步:

  1. 建立 HTTPS 连接:声明HTTPClient(封装了与 REST API 交互的方法),用httpClient.begin(client, PREDICTION_URL)把之前配置好根证书的WiFiClientSecure实例与预测 URL 绑定,从而复用安全连接;
  2. 设置请求头Content-Type: application/octet-stream告诉服务端请求体是原始二进制数据(JPEG 字节流);Prediction-Key头携带 Custom Vision 预测密钥用于鉴权;
  3. 发送 POST 请求httpClient.POST(buffer, length)把图像字节数组上传。POST 请求的语义是「发送数据并获取响应」,与浏览器加载网页所用的 GET 请求(仅获取数据)不同;
  4. 检查响应状态码:HTTP 状态码是预定义好的取值,200表示OK,即请求成功;其他取值(如 400/401/500 等)代表失败,此时函数直接跳过解析并释放连接;
  5. 解析 JSON 响应:成功时用getString()读取响应文本,交由 ArduinoJson 反序列化,遍历predictions数组。

预测响应的 JSON 格式

Custom Vision 预测接口返回的 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数组:每个训练过的标签(tag)都会在数组中占一条记录,包含tagName(标签名)和probability(概率)。概率是 0~1 之间的浮点数,0 表示该图 0% 匹配此标签,1 表示 100% 匹配。代码用sprintf把概率乘以 100 并格式化为两位小数百分比,逐条打印到串口监视器。

代码中DynamicJsonDocument doc(1024)为解析分配了 1 KB 的堆内存,足以容纳上述响应;如果你的模型标签更多、返回体更大,需要相应调大该数值。

任务四:接入按钮回调并烧录验证

buttonPressed函数中调用classifyImage。有两种做法:

  • 替换 SD 卡保存逻辑:把原先写入 microSD 卡的代码替换为分类调用(同时可以删掉setupSDCardsaveToSDCard函数,让工程更精简);
  • 追加调用:在图像写入 SD 卡之后、缓冲区被delete之前调用。

无论哪种方式,调用必须发生在缓冲区释放之前。最终buttonPressed的形态参考:

void 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); classifyImage(buffer, length); delete (buffer); } }

编译上传后,把摄像头对准水果并按下 C 按钮,串口监视器输出如下:

Connecting to WiFi.. Connected! Image captured Image read to buffer with length 8200 ripe: 56.84% unripe: 43.16%

同时,这次预测也会出现在 Custom Vision 门户的Predictions页签中,你可以直接看到对应的原始图像与预测值:

预测 URL 与 Prediction-Key 的获取界面如下图所示,这是配置config.h中两个常量的依据:

预测结果解读与模型迭代改进

图像分类器会为所有参与训练的标签返回概率,因此每个标签都会有一个「图像匹配该标签」的置信度;所有标签概率之和为 1(即 100%),ripe: 56.84%unripe: 43.16%正是这种分布的体现。

需要注意,设备摄像头拍摄的图像与训练时上传的图像可能存在明显差异(分辨率、白平衡、光照、锐度等),这会导致预测准确率下降。课程 README.md 给出的改进路径是:

  1. 用 IoT 设备拍摄多张成熟与未成熟水果的图像;
  2. 在 Custom Vision 门户的Predictions页签中把这些真实设备图像标记后用于重新训练(具体重训方法见 上一课文档);
  3. 如果设备图像与原始训练图像差异过大,可在Training Images页签删除原图;
  4. 训练新迭代并重新发布,更新代码中的预测 URL 后重新运行;
  5. 反复迭代直到预测结果满意。

训练数据与预测数据越接近,分类器的实际效果越好。

小结

至此,fruit-quality-detector已经打通「拍照 → HTTPS 上传 → 云端分类 → 串口输出」的完整闭环。回顾本文关键点:

  • 微控制器必须手动配置 TLS 根证书(Azure DigiCert Global Root G2),通过WiFiClientSecure::setCACert建立 HTTPS 连接;
  • 预测端点参数(PREDICTION_URLPREDICTION_KEY)集中在 config.h 管理;
  • classifyImage使用HTTPClient.POST发送application/octet-stream二进制图像,以状态码 200 判断成功,再以 ArduinoJson 解析predictions数组并格式化输出;
  • 完整可运行工程位于 code-classify/wio-terminal。

后续练习可参考本课的 assignment.md:尝试根据分类结果作出响应(例如通过屏幕或外设反馈水果质量),让设备从「被动识别」走向「主动反馈」。

【免费下载链接】IoT-For-Beginners12 Weeks, 24 Lessons, IoT for All!项目地址: https://gitcode.com/GitHub_Trending/io/IoT-For-Beginners

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

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

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

立即咨询