TinyML 影像辨識微型化:在成本 10 美元的 ESP32-CAM 開發板上即時分類貓狗,從此跟麻煩的 WiFi 連線說拜拜(使用 MobileNet v1 模型與遷移學習)

Alan Wang
27 min readFeb 19, 2022
Photo by kevin turcios on Unsplash

Ai-Thinker 的 ESP32-CAM 開發板(搭配 OV2640 200 萬畫素攝影鏡頭)推出其實已經很多年了,一直因為其低廉的成本和看似潛力無窮的應用空間而受到關注,唯一較不便的是因為沒有 USB 燒錄介面,得自己接一個 USB-TTL 模組,或者買搭配的 MB 底板。而 Espressif 給它寫的官方範例,同時展示了網路即時串流和人臉辨識的能力。

去年有人替它寫了個能把 Edge Impulse 上訓練好的模型上傳到 ESP32-CAM 使用的 Arduino 範例,而原始碼也被放在 Edge Impulse 的官方 Github repo。我在別篇文章介紹過 Edge Impulse;雖然 Edge Impulse 一直沒有正式支援 ESP32-CAM,但看來要訓練並移植模型是完全可行的。

不過,我在試驗上面那個範例時卻發現它丟出一堆遺失函式庫的訊息,查了一下才發現這些函式庫的來源 repo(esp-face)已經被大幅改造成 esp-dl,API 介面已經截然不同,而且更著重在 Espressif 自家更昂貴的 ESP-EYE 開發板。

Photo by Ehimetalor Akhere Unuabona on Unsplash

我把上面別人 forked 的函式庫拷貝過來,那個 Edge Impulse 範例就能正常上傳了。不過就和很多既有的 ESP32-CAM 範例一樣,它必須連上(穩定的)Wifi,你也得從序列埠讀 IP 位址、再從電腦瀏覽器連它才看得到圖。此外原始範例是你重整頁面(重送請求)時才會再抓一張圖,這使得實際應用的便利性上就打了很大的折扣。

因此這便是我改寫這個範例的主要動機:改用一個 TFT LCD 螢幕來顯示影像和分類結果,完全捨棄 WiFi。我可以按個鈕拍照(由於模型推論結果要時間,而且需要看結果,所以不能直接放在迴圈裡一直跑),完全不需動用到額外電腦設備。換言之:我想做一個真正可攜帶的智慧裝置,實現 TinyML 的理想。

資料集:Kaggle Cats and Dogs Dataset

和原始範例不同的是,我用了一個比較大、由微軟提供的的圖像資料集。這原本是用於 Kaggle 的一個競賽,用來看深度學習能否通過 CAPTCHA 圖像測試(用來辨識使用者是不是真人)。資料集包含 25,000 張圖片,當中貓狗各半。由於每張圖的貓狗都很不一樣,這剛好能用來建立一個效能不錯的辨識模型。

上面這張圖是我之前在用 AutoKeras 練習做 Kaggle 貓狗辨識競賽時,對測試集一部分圖產生的預測結果,而我用的是訓練很耗時、模型龐大但準確率也很高的 EfficientNet B7。你可以在這裡找到我的 Notebook

在 Edge Impulse 遷移訓練 MobileNet v1 模型

Edge Impulse 其實提供了不少模型選項,包括自己從頭訓練 Tensorflow 模型或者用做多重物件辨識的 SSD-MobileNet(我猜大概和官方文件展示的一樣,只適用於樹莓派 4、NVIDIA Jetson Nano 或手機)。我們在此使用的是一般的 MobileNet,也就是用整張畫面當作輸入,一次只產生一個判斷結果。我們也會用遷移學習來訓練它,以便在較短時間內得到結果。

其實 Teachable Machine 也是藉由 MobileNet 遷移學習來產生模型的。

Photo by Dmitry Ratushny on Unsplash

原始文章有關於如何在 Edge Impulse 訓練模型的指示,此外我也我把我的 Edge Impulse 專案發布在下面:

在 Dashboard 的 Acquire data 選 Upload data,然後上傳圖片。由於資料集解開後,貓狗圖片剛好各占前後一半,所以我們可以分別用 cat 和 dog 的標籤上傳(要確定選擇 Automatically split between training and testing,也就是自動分割成訓練集和測試集)。(上傳時有些圖片可能因某種原因無法使用,所以最終我成功上傳的量是 24,969 張。)

接著在 create impulse 頁籤中,我的輸入影像大小是 96 x 96 並選擇 Fit shortest axis(根據較短邊來裁切圖片,這樣縮成正方形就不會變形)。

完成後到 image 頁籤產生圖像特徵,然後到 Transfer learning 選擇並訓練模型。為了盡可能增進效能,我選了裡面最簡單的 MobileNetV1 96x96 0.25。

特別注意:Edge Impulse 有 20 分鐘的訓練時間上限,超過了訓練程序就會 timeout,所以試驗了幾次後,我只讓它訓練 5 cycle(5 epochs?),學習率用 0.001 而不是預設的 0.0005。

其實你可以寫信請管理員替你延長時間就是…不過我懶得這樣做了。

我想如果訓練圖片沒有很多的話,要用複雜一點的模型以及訓練更多 cycles 應該是可行的。不過以我的例子,產生的結果也比原始文章好得多就是了。

Photo by Steven Lelham on Unsplash

我的模型在訓練集得到 89.8% 準確率,而對測試集則有 86.97 準確率:

可惜的是我無法使用 EON Tuner 來做自動化調校模型,因為那得針對特定裝置來最佳化,但 ESP32-CAM 尚未在官方支援之列。你在站上看到的模型推論時間也不可信,因為那是以別的裝置來計算的。

總之完成訓練後,在 Deployment 頁籤點 Arduino library,然後捲到最底下按 Build。完成後你會得到一個 .zip 格式的 Arduino library,把它匯入到 Arduino IDE 即可。

軟體環境

你會需要

  • 在偏好設定→開發板管理員網址加入 Arduino-ESP32,安裝 esp32 支援然後選擇 Ai Thinker ESP32-CAM
  • 安裝 TFT 函式庫 Adafruit GFX Library 和 Adafruit ST7735 and ST7789 Library
  • 匯入上面你下載的 Edge Impulse 函式庫(以我為例是 ei-esp32-cam-cat-dog-arduino-1.0.4.zip

注意:若你要讀序列埠輸出,請使用 Arduino IDE 1.x。新的 2.0 版會沒法讀到程式中 Edge Impulse 物件用 ei_printf() 輸出的除錯資訊。

最後則是下載我的範例:

Photo by Charles Deluvio on Unsplash

這裡面有兩個資料夾:

  • edge-impulse-esp32-cam 是下面要講的版本
  • edge-impulse-esp32-cam-bare 是不使用螢幕或其他外接裝置的版本,若你不想搞下面那堆接線,可以玩玩看這個

無論用哪一個,把開頭的

#include <esp32-cam-cat-dog_inferencing.h>

換成指向你的 Edge Impulse 函式庫。如果不知道名字,去 IDE 範例選單找有你專案名稱的那個函式庫,隨便打開一個程式,看裡面寫什麼即可。

完整版主程式如下:

/*
Live Image Classification on ESP32-CAM and ST7735 TFT display
using MobileNet v1 from Edge Impulse
Modified from https://github.com/edgeimpulse/example-esp32-cam.
Note:
The ST7735 TFT size has to be at least 120x120.
Do not use Arduino IDE 2.0 or you won't be able to see the serial output!
*/
#include <esp32-cam-cat-dog_inferencing.h> // replace with your deployed Edge Impulse library#define CAMERA_MODEL_AI_THINKER#include "img_converters.h"
#include "image_util.h"
#include "esp_camera.h"
#include "camera_pins.h"
#include <Adafruit_GFX.h> // Core graphics library
#include <Adafruit_ST7735.h> // Hardware-specific library for ST7735
#define TFT_SCLK 14 // SCL
#define TFT_MOSI 13 // SDA
#define TFT_RST 12 // RES (RESET)
#define TFT_DC 2 // Data Command control pin
#define TFT_CS 15 // Chip select control pin
// BL (back light) and VCC -> 3V3
#define BTN 4 // button (shared with flash led)dl_matrix3du_t *resized_matrix = NULL;
ei_impulse_result_t result = {0};
Adafruit_ST7735 tft = Adafruit_ST7735(TFT_CS, TFT_DC, TFT_MOSI, TFT_SCLK, TFT_RST);// setup
void setup() {
Serial.begin(115200);
// button
pinMode(4, INPUT);
// TFT display init
tft.initR(INITR_GREENTAB); // you might need to use INITR_REDTAB or INITR_BLACKTAB to get correct text colors
tft.setRotation(0);
tft.fillScreen(ST77XX_BLACK);
// cam config
camera_config_t config;
config.ledc_channel = LEDC_CHANNEL_0;
config.ledc_timer = LEDC_TIMER_0;
config.pin_d0 = Y2_GPIO_NUM;
config.pin_d1 = Y3_GPIO_NUM;
config.pin_d2 = Y4_GPIO_NUM;
config.pin_d3 = Y5_GPIO_NUM;
config.pin_d4 = Y6_GPIO_NUM;
config.pin_d5 = Y7_GPIO_NUM;
config.pin_d6 = Y8_GPIO_NUM;
config.pin_d7 = Y9_GPIO_NUM;
config.pin_xclk = XCLK_GPIO_NUM;
config.pin_pclk = PCLK_GPIO_NUM;
config.pin_vsync = VSYNC_GPIO_NUM;
config.pin_href = HREF_GPIO_NUM;
config.pin_sscb_sda = SIOD_GPIO_NUM;
config.pin_sscb_scl = SIOC_GPIO_NUM;
config.pin_pwdn = PWDN_GPIO_NUM;
config.pin_reset = RESET_GPIO_NUM;
config.xclk_freq_hz = 20000000;
config.pixel_format = PIXFORMAT_JPEG;
config.frame_size = FRAMESIZE_240X240;
config.jpeg_quality = 10;
config.fb_count = 1;
// camera init
esp_err_t err = esp_camera_init(&config);
if (err != ESP_OK) {
Serial.printf("Camera init failed with error 0x%x", err);
return;
}
sensor_t * s = esp_camera_sensor_get();
// initial sensors are flipped vertically and colors are a bit saturated
if (s->id.PID == OV3660_PID) {
s->set_vflip(s, 1); // flip it back
s->set_brightness(s, 1); // up the brightness just a bit
s->set_saturation(s, 0); // lower the saturation
}
Serial.println("Camera Ready!...(standby, press button to start)");
tft_drawtext(4, 4, "Standby", 1, ST77XX_BLUE);
}
// main loop
void loop() {
// wait until the button is pressed
while (!digitalRead(BTN));
delay(100);
// capture a image and classify it
String result = classify();
// display result
Serial.printf("Result: %s\n", result);
tft_drawtext(4, 120 - 16, result, 2, ST77XX_GREEN);
}
// classify labels
String classify() {
// run image capture once to force clear buffer
// otherwise the captured image below would only show up next time you pressed the button!
capture_quick();
// capture image from camera
if (!capture()) return "Error";
tft_drawtext(4, 4, "Classifying...", 1, ST77XX_CYAN);
Serial.println("Getting image...");
signal_t signal;
signal.total_length = EI_CLASSIFIER_INPUT_WIDTH * EI_CLASSIFIER_INPUT_WIDTH;
signal.get_data = &raw_feature_get_data;
Serial.println("Run classifier...");
// Feed signal to the classifier
EI_IMPULSE_ERROR res = run_classifier(&signal, &result, false /* debug */);
// --- Free memory ---
dl_matrix3du_free(resized_matrix);
// --- Returned error variable "res" while data object.array in "result" ---
ei_printf("run_classifier returned: %d\n", res);
if (res != 0) return "Error";
// --- print the predictions ---
ei_printf("Predictions (DSP: %d ms., Classification: %d ms., Anomaly: %d ms.): \n",
result.timing.dsp, result.timing.classification, result.timing.anomaly);
int index;
float score = 0.0;
for (size_t ix = 0; ix < EI_CLASSIFIER_LABEL_COUNT; ix++) {
// record the most possible label
if (result.classification[ix].value > score) {
score = result.classification[ix].value;
index = ix;
}
ei_printf(" %s: \t%f\r\n", result.classification[ix].label, result.classification[ix].value);
tft_drawtext(4, 12 + 8 * ix, String(result.classification[ix].label) + " " + String(result.classification[ix].value * 100) + "%", 1, ST77XX_ORANGE);
}
#if EI_CLASSIFIER_HAS_ANOMALY == 1
ei_printf(" anomaly score: %f\r\n", result.anomaly);
#endif
// --- return the most possible label ---
return String(result.classification[index].label);
}
// quick capture (to clear buffer)
void capture_quick() {
camera_fb_t *fb = NULL;
fb = esp_camera_fb_get();
if (!fb) return;
esp_camera_fb_return(fb);
}
// capture image from cam
bool capture() {
Serial.println("Capture image...");
esp_err_t res = ESP_OK;
camera_fb_t *fb = NULL;
fb = esp_camera_fb_get();
if (!fb) {
Serial.println("Camera capture failed");
return false;
}
// --- Convert frame to RGB888 ---
Serial.println("Converting to RGB888...");
// Allocate rgb888_matrix buffer
dl_matrix3du_t *rgb888_matrix = dl_matrix3du_alloc(1, fb->width, fb->height, 3);
fmt2rgb888(fb->buf, fb->len, fb->format, rgb888_matrix->item);
// --- Resize the RGB888 frame to 96x96 in this example ---
Serial.println("Resizing the frame buffer...");
resized_matrix = dl_matrix3du_alloc(1, EI_CLASSIFIER_INPUT_WIDTH, EI_CLASSIFIER_INPUT_HEIGHT, 3);
image_resize_linear(resized_matrix->item, rgb888_matrix->item, EI_CLASSIFIER_INPUT_WIDTH, EI_CLASSIFIER_INPUT_HEIGHT, 3, fb->width, fb->height);
// --- Convert frame to RGB565 and display on the TFT ---
Serial.println("Converting to RGB565 and display on TFT...");
uint8_t *rgb565 = (uint8_t *) malloc(240 * 240 * 3);
jpg2rgb565(fb->buf, fb->len, rgb565, JPG_SCALE_2X); // scale to half size
tft.drawRGBBitmap(0, 0, (uint16_t*)rgb565, 120, 120);
// --- Free memory ---
rgb565 = NULL;
dl_matrix3du_free(rgb888_matrix);
esp_camera_fb_return(fb);
return true;
}
int raw_feature_get_data(size_t offset, size_t out_len, float *signal_ptr) { size_t pixel_ix = offset * 3;
size_t bytes_left = out_len;
size_t out_ptr_ix = 0;
// read byte for byte
while (bytes_left != 0) {
// grab the values and convert to r/g/b
uint8_t r, g, b;
r = resized_matrix->item[pixel_ix];
g = resized_matrix->item[pixel_ix + 1];
b = resized_matrix->item[pixel_ix + 2];
// then convert to out_ptr format
float pixel_f = (r << 16) + (g << 8) + b;
signal_ptr[out_ptr_ix] = pixel_f;
// and go to the next pixel
out_ptr_ix++;
pixel_ix += 3;
bytes_left--;
}
return 0;
}
// draw test on TFT
void tft_drawtext(int16_t x, int16_t y, String text, uint8_t font_size, uint16_t color) {
tft.setCursor(x, y);
tft.setTextSize(font_size); // font size 1 = 6x8, 2 = 12x16, 3 = 18x24
tft.setTextColor(color);
tft.setTextWrap(true);
tft.print(strcpy(new char[text.length() + 1], text.c_str()));
}

上傳程式

ESP32-CAM 上傳程式總是有點棘手。目前最簡單的方式是買附有底板(ESP32-CAM-MB,需要 CH340 驅動程式)的,先插著底板燒錄,再把主板拔下來。

如果沒有底板,那麼你需要一個 USB 轉 TTL 序列模組,據我所知大部分的都能用(但你可能得確定電腦上有 CP210x 或 CH340 驅動程式)。

簡單來說用 TTL 燒錄的方式如下:

  1. 拔掉電源(確定你有穩定充足的 5V 電源;TTL 模組本身的電源可能不見得夠)
  2. TTL 模組的 Tx 接 GPIO 3 (UOR),Rx 接 GPIO 1 (UOT),GND 接 GND。如果用外部供電的話,GND 最好接在外部供電的 GND 而不是板子上。
  3. 將 GPIO 0 接 GND。重新通電後,這會讓板子進入燒錄模式。
  4. 從 Arduino IDE 編譯並上傳程式。

如果上傳時一直在等待,板子卻沒有回應的話,就只能試著重新通電和重來了。

總之 Edge Impulse 函式庫背後有 Tensorflow Lite Micro,因此第一次編譯的時間會很長,就耐心等等啦。

接線

上面我把所有東西畫在同一塊麵包板上,而我自己做的是把 ESP32-CAM 放在另一側的小麵包板,這樣我可以像拿著相機一樣「取景」。

我們需要的外部裝置如下:

  • 可提供穩定 5V 與 3.3V 的電源模組
  • ST7735 TFT LCD 螢幕(160 x 128 或 128 x 128 皆可)
  • 按鈕 x 1 和 10 KΩ 電阻 x 2
  • USB-TTL 模組(若你想燒錄或讀取序列埠輸出的話)

我用的電源模組需要輸入 6V+ 才能穩定供應 5V,所以我用 7.5V 1A 充電器。但我用的模組本身只會輸出到 500 mA,這其實跟一般電腦 USB 孔一樣。既然現在不使用 WiFi,整體耗電量就不會很大。

TFT 螢幕接線如下:

SCK (SCL)   GPIO 14
MOSI (SDA) GPIO 13
RESET (RST) GPIO 12
DC GPIO 2
CS GPIO 15
BL (背光) 3V3

此外一個小地方是 ST7735 有幾種變異版,像是 green tab、red tab 或 black tab。我手上有另一塊 ST7735,就我測試對顯示影像本身來說似乎沒影響,但文字的紅綠藍順序會改變。你可以修改程式中的這行:

tft.initR(INITR_GREENTAB); // 改成 INITR_REDTABINITR_BLACKTAB
Photo by Marília Castelli on Unsplash

最後,按鈕則是接在 GPIO 4。我們不能用 GPIO 16,因為跟攝影機設定有關。但 GPIO 4 剛好又跟板子上的閃光燈共用,如果像一般按鈕那樣弄成上拉電阻,燈就會一直亮著…因此這也是為何我們要給按鈕裝 10 KΩ 下拉電阻。

如果你覺得拍照時有點照明也不錯的話,或許可把兩個電阻都換成比較小的,讓更多電流通過 GPIO 4。但這樣有多安全我就不確定了…XD

實測

整個系統接好線和通電後,螢幕應該會變成黑色並出現「Standby」。這時按下按鈕,ESP32-CAM 會拍攝一張畫面並開始推論分類:

如以上影片所示,現在我們再也不需倚賴額外的電腦設備,就能直接進行手持影像辨識,也不需瞎猜拍攝位置究竟是不是對的。

從序列埠的輸出結果來看,這個 TinyML 模型的表現其實不錯,至於推論時間(預測分類的所需時間)則是 2.6 秒,感覺沒有很快。

但我們得記住:本文我們打造的裝置總成本極低,可能不到台幣 800 元吧,而且實作上並不困難。而既然現在可以完全「離線」運作,它就有在真實世界實際應用的潛力了。

一些最後的小註腳

Photo by Glenn Carstens-Peters on Unsplash

其實我一開始在嘗試把影像顯示在 TFT 螢幕上時並不順利,因為網路上的ESP32-CAM 螢幕輸出範例幾乎都是使用一個叫 TJpg_Decoder 的函式庫來繪圖,而它在解碼 JPEG(攝影機抓出來的 frame 的 buffer 格式)時需要很多記憶體。若同時跟影像模型一起跑,ESP32-CAM 就會因記憶體不足而掛掉重開。

結果仔細研究了一下,ESP32-CAM 的官方驅動程式裡面就有一個可從 JPEG 轉 RGB565 的函式,後者是 Adafruit 函式庫用的顯示格式,而且可以在轉換時指定縮放(邊長縮 1/2,所以變成整張圖縮 1/4)。因此程式內的運作流程如下:

  1. (來自原範例)攝影機拍攝的影像大小是 240 x 240(有 96 x 96 選項但我試過無法產生能正確顯示的影像)。
  2. (來自原範例)程式會把這張圖轉成 RGB888 格式並縮成 96 x 96 給模型使用。
  3. (我的新增部分)最後把原影像轉成 RGB565,並縮成 120 x 120,以便讓整張圖能顯示在螢幕上。
Photo by Chris Barbalis on Unsplash

此外,原始範例還有個很奇怪的現象:每次重整瀏覽器時,傳回的都是前一次重整瀏覽器所拍攝的畫面,也就是畫面會 lag。我在測試離線版時也有這種現象。後來的解法是在按鈕之後要 ESP32-CAM 拍兩次照,第一次只是把圖讀進 buffer 和丟掉,第二次的才會做上述處理。由於取得影像的時間也蠻快的,第二次就會把鏡頭前的東西拍進去了。

官方其實還有提供很多功能,可調整攝影機曝光或白平衡之類的表現,你要在特定環境下操作的話可考慮使用,這邊就不介紹了:

Photo by Drew Coffman on Unsplash

--

--