ディープラーニングのお勉強~その7。M5StackとM5Cameraで手書き数字リアルタイム画像認識実験~

M5StackとM5Cameraで畳み込みニューラルネットワーク画像認識実験 Deep-Learning

こんばんは。

今回も引き続きディープラーニングのお勉強で第7弾ですが、なかなか凄いですよ。
M5Cameraの映像をWiFiで飛ばしてM5StackのLCD(液晶ディスプレイ)にリアルタイム表示して、手書き数字を畳み込みニューラルネットワークでリアルタイム画像認識させる実験をしてみました。
もうここまで来たら、ほぼAI搭載M5Stackと言えそうです。(いやいや、まだほんの第一歩です)

スポンサーリンク

M5Stackのディスプレイ表示には、この界隈のレジェンド、らびやんさん作成のLovyanGFXライブラリを使わせて頂きました。
以前のこちらの記事でも使わせて頂きましたが、とにかくこのライブラリは液晶表示が高速なんです。
そのおかげで、平均24fpsを確保しながら畳み込みニューラルネットワーク(CNN)演算が可能になりました。
とにかく以下の動画をご覧ください。

どうっすか?
なかなか良い感じですよね。

前回の記事ではシリアルモニタに表示させていましたが、さすがに表示が遅く、タイムラグも大きくてイマイチでしたが、液晶表示でここまで高速にできると、畳み込みニューラルネットワーク(CNN)演算を本当にやっているのか不安になるほどです。
でも、しっかり手書き数字を判定できていますね。

M5StackのBボタン(中央のボタン)を押すと、LCDの左下に前処理した画像を表示させています。
それは、白黒反転させてMaxPooling処理して28×28 pixelに圧縮したものを3倍に引き伸ばして表示させています。
それにフォントも引き伸ばして表示させています。
これでもフレームレートは落ちません。
こんなことはLovyanGFXが無かったら出来ないですね。
改めてLovyanGFXの凄さを実感しました。
このライブラリを作って、日々進化させているらびやんさんにはもう感謝しかありませんね。

さて、畳み込みニューラルネットワーク(CNN)の計算時間は約23msほどに抑えられました。
画像の前処理も含めた時間はpCNNというところに表示させていますが、それが約33msほどです。
自作の関数でこれだけ高速にできれば充分です。

ただ、カメラ画像の全フレームにCNNを実行させると、825msほどかかってしまいます。
すると、カメラ画像表示にタイムラグが生じて、扱いにくくなります。
結局、CNNは全フレームで計算させる必要は無いことが分かり、200ms毎でも充分です。
動画にあるように、左下の前処理画像は5fpsで表示しています。
こうすると、CNN演算させても、リアルタイムカメラ画像は約24fpsを保ったままなので、あたかもCNN画像認識もリアルタイムでできているかのように見えます。
これで充分ですね。
本当はマルチタスクにしても良いと思いますが、今回はそこまでの必要は無かったです。

ということで、これから今回の実験を説明します。
因みに何度も言っておりますが、私はプログラミングもディープラーニングも独学の素人です。
誤りや勘違いが多々あると思いますので、お気づきの点があればコメント投稿でご連絡いただけると助かります。

    【目次】

    事前準備

  1. まずはM5Cameraの映像をM5StackのLCDにWiFi TCPでストリーミング表示させる方法を再考してみる
  2. M5Stackスケッチに畳み込みニューラルネットワーク(CNN)を組み込む
  3. 今後の課題点
  4. まとめ

事前準備

使用ったもの

M5Stack Basic

このブログで何度も使用している、ESP32搭載の技適取得済みWiFi and Bluetooth搭載マイコンモジュールです。
LCD(液晶ディスプレイ)やmicro SDカードスロット、ボタン、バッテリー等を搭載した全部入りモジュールです。

(追記)
M5Stack Basicは、この記事を書いた当時より格段にバージョンアップしております。
以下のスイッチサイエンスさんの公式サイトをご参照ください。
https://www.switch-science.com/collections/%E5%85%A8%E5%95%86%E5%93%81/products/9010

M5Camera

ESP32-WROVERと、イメージセンサOV2640搭載のWiFiマイコンモジュールです。
スイッチサイエンスさんで販売しています。

https://www.switch-science.com/catalog/5207/

Amazonでは残念ながら売っていませんでした。

M5Cameraについては以下の記事でレビューしたことがあるので、参照してみてください。
M5Camera をレビューしてみた。分解したり、Arduino IDE でスマホに映したりする実験

IoT対応モバイルバッテリー

最初に紹介した動画で使用していたモバイルバッテリーは、IoT機器対応のモバイルバッテリーです。
cheero canvas CHE-061-IOT
というものです。
一般的なスマホ用のモバイルバッテリーでは、微小な使用電力の場合は数分後には自動的に電源遮断してしまいますが、このcheeroのIoT対応モバイルバッテリーは、微小電力でも電源断の無い、電子工作家には有難い製品でおススメです。

M5Stack用電池モジュール

M5Stack Basicに搭載されているバッテリーではあまり使い物にならないので、オプションのバッテリーを積んだ方が良いかもしれません。
ただ、これを搭載すると、ボトムにあるバッテリーとの間で循環電流が発生して、バッテリー寿命を縮める可能性があるようです。
よって、このモジュールを使う場合は、ボトムバッテリーをケースから取り外してしまった方が良いらしいです。

(※2021年11月時点では、バッテリー容量は750 mAhになっています)
M5Stack用電池モジュール

パソコンおよびUSBケーブル等

USBケーブルはできるだけ信頼性のある、太く短く良質なケーブルを使用します。
パソコンは、SONY Neural Network Console のダウンロード版を使用する場合はWindowsパソコンとなります。

WiFi環境

WiFiルーター環境が必要です。
事前にファイアウォールやMACアドレスフィルタリング等の設定を見直して置き、M5StackやM5Cameraが接続できる状態にしておいてください。

Arduino core for the ESP32 のインストール

Arduino IDE はver 1.8.13 で動作確認しています。
Arduino core for the ESP32 は、stable ver 1.0.4 で動作確認しています。
Arduino core for the ESP32 のインストール方法は以下の記事を参照してください。

Arduino core for the ESP32 のインストール方法

M5Stack公式ライブラリのインストール

M5Stack公式Arduinoライブラリは、ボタン操作のみの処理で使用します。
ver 0.3.1 で動作確認しています。
M5Stack公式Arduinoライブラリのインストール方法は以下の記事を参照してください。

M5Stack ライブラリインストール方法 ( Arduino IDE 用)

LovyanGFXライブラリのインストール

M5StackのLCD(液晶ディスプレイ)表示にはらびやんさん作成の高速表示ライブラリ LovyanGFX を使いました。
M5Stack公式ライブラリのインストール方法と同様に、ライブラリ検索欄に「LovyanGFX」と入力すればすぐに見つけられると思います。
同様にインストールしておけばOKです。
ver 0.3.4 で動作確認しています。

1.まずはM5Cameraの映像をM5StackのLCDにWiFi TCPでストリーミング表示させる方法を再考してみる

まずは、M5Cameraで取得した映像データをM5StackのLCD(液晶ディスプレイ)に表示させるわけですが、実は以前に何度もこのブログで実験していたんです。
ですが、今回はM5Camera側のスケッチをもっと簡単にしたりして、ちょっとだけ再考してみたいと思います。

半年近く前の以下の記事
LovyanGFXとJpgLoopAnimeでM5StackとM5Cameraの全画面WiFi動画ストリーミング実験
で、イメージセンサOV2640からDMA(Direct Memory Access)でJPEGデータを取得し、WiFi TCPでM5Stackに送信して、LCD(液晶ディスプレイ)にストリーミング表示させる実験をしました。

この記事にあるように、M5Cameraのプログラミングはパソコンのブラウザでも表示できることが個人的には重要ですので、今回もMJPEG(Motion JPEG)方式で送信することにします。

そして、その記事では、esp32-cameraライブラリを解体して独自にアレンジしてみましたが、イメージセンサとESP32のDMA(Direct Memory Access)転送も大体理解してきたので、今回はesp32-cameraライブラリのサンプルスケッチに習い、簡略化してみることにします。

1-1. M5Camera側のスケッチ入力

esp32-cameraライブラリのサンプルスケッチ、CameraWebServerを参考にしました。
そして、その中のapp_httpd.cppも参考にさせていただきました。

app_httpd.cppのソースコードは難解です。
そして不要な部分も多いので、必要最低限の部分を抽出しました。
結果、400行以下に収めることができました。

とりあえず、こんな感じです。

(このソースコードに記載したWiFiアクセスポイントのssidやパスワードは、コンパイル後でもデバイスさえあればツールによって第三者が簡単に抜き取ることができます。充分注意してください。当方では一切責任を負いませんので、各個人がデバイスを厳重に管理することをお勧めします。)

/* This is a program modified by mgo-tec from the esp32-camera library and CameraWebServer sketch.
 * 
 * The MIT License (MIT)
 * License URL: https://opensource.org/licenses/mit-license.php
 * Copyright (c) 2020 Mgo-tec. All rights reserved.
 * 
 * Use Arduino core for the ESP32 stable v1.0.4
 *  
 * Modify app_httpd.cpp(Arduino core for the ESP32 v1.0.4).
 * Copyright 2015-2016 Espressif Systems (Shanghai) PTE LTD
 * app_httpd.cpp - Licensed under the Apache License, Version 2.0
 *     http://www.apache.org/licenses/LICENSE-2.0
 *     
 * esp32-camera library ( Copyright 2015-2016 Espressif Systems (Shanghai) PTE LTD)
 *  Licensed under the Apache License, Version 2.0 (the "License").
 *  URL:https://github.com/espressif/esp32-camera
 */
#include <WiFi.h>
#include <esp_camera.h>
#include <esp_http_server.h>

const char* ssid = "xxxxxxxxx"; //ご自分のルーターのSSIDに書き換えてください
const char* password = "xxxxxxxxx"; //ご自分のルーターのパスワードに書き換えてください

#define PWDN_GPIO_NUM     -1
#define RESET_GPIO_NUM    15
#define XCLK_GPIO_NUM     27
#define SIOD_GPIO_NUM     22 //M5Camera model A #25
#define SIOC_GPIO_NUM     23
#define Y9_GPIO_NUM       19
#define Y8_GPIO_NUM       36
#define Y7_GPIO_NUM       18
#define Y6_GPIO_NUM       39
#define Y5_GPIO_NUM        5
#define Y4_GPIO_NUM       34
#define Y3_GPIO_NUM       35
#define Y2_GPIO_NUM       32
#define VSYNC_GPIO_NUM    25 //M5Camera model A #22
#define HREF_GPIO_NUM     26
#define PCLK_GPIO_NUM     21

httpd_handle_t stream_httpd = NULL;
httpd_handle_t camera_httpd = NULL;

static bool canStartStream = false;
static bool canSendImage = false;
static bool isWiFiConnected = false;
static bool isCloseConnection = false;

static uint8_t fps_count = 0;

void setup() {
  Serial.begin(115200);
  Serial.println();

  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.jpeg_quality = 40; //※画質(10~60)
  config.frame_size = FRAMESIZE_QQVGA; //160x120 pixel
  config.fb_count = 1;

  //esp_cameraライブラリ初期化
  esp_err_t err = esp_camera_init(&config);
  if (err != ESP_OK) {
    Serial.printf("Camera init failed with error 0x%x", err);
    return;
  }

  //イメージセンサOV2640の設定
  sensor_t *sensor = NULL; //M5Cameraのセンサ設定用
  sensor = esp_camera_sensor_get();
  sensor->set_hmirror(sensor, 1); //M5Cameraのモデルによる
  sensor->set_vflip(sensor, 1); //M5Cameraのモデルによる
  sensor->set_exposure_ctrl(sensor, 1); //露出制御ON
  sensor->set_aec2(sensor, 1); //自動露出制御ON
  sensor->set_whitebal(sensor, 1); //ホワイトバランスON
  sensor->set_awb_gain(sensor, 1); //自動ホワイトバランスゲインON

  connectToWiFi();
  while(!isWiFiConnected){
    delay(1);
  }
  startHttpd();
}

void loop() {
}

//****************************************
void connectToWiFi(){
  Serial.println("Connecting to WiFi network: " + String(ssid));
  WiFi.disconnect(true, true);
  delay(1000);
  WiFi.onEvent(WiFiEvent);
  WiFi.begin(ssid, password);
  Serial.println("Waiting for WIFI connection...");
}

void WiFiEvent(WiFiEvent_t event){
  switch(event) {
    case SYSTEM_EVENT_STA_GOT_IP:
      Serial.println("WiFi connected!");
      Serial.print("My IP address: ");
      Serial.println(WiFi.localIP());
      delay(1000);
      isWiFiConnected = true;
      break;
    case SYSTEM_EVENT_STA_DISCONNECTED:
      Serial.println("WiFi lost connection");
      isWiFiConnected = false;
      break;
    default:
      break;
  }
}
//****************************************
void startHttpd(){
  httpd_config_t config = HTTPD_DEFAULT_CONFIG();

  httpd_uri_t index_uri = {
        .uri       = "/",
        .method    = HTTP_GET,
        .handler   = index_handler,
        .user_ctx  = NULL
    };

  httpd_uri_t cmd_uri = {
        .uri       = "/control",
        .method    = HTTP_GET,
        .handler   = cmd_handler,
        .user_ctx  = NULL
    };

  httpd_uri_t stream_uri = {
      .uri       = "/stream",
      .method    = HTTP_GET,
      .handler   = stream_handler,
      .user_ctx  = NULL
  };

  if (httpd_start(&camera_httpd, &config) == ESP_OK) {
    httpd_register_uri_handler(camera_httpd, &index_uri);
    httpd_register_uri_handler(camera_httpd, &cmd_uri);
  }

  config.server_port += 1;
  config.ctrl_port += 1;

  if (httpd_start(&stream_httpd, &config) == ESP_OK) {
    httpd_register_uri_handler(stream_httpd, &stream_uri);
  }
}
//****************************************
static esp_err_t index_handler(httpd_req_t *req){
  //スマホやPCでカメラ画像を表示するためだけのHTML設定
  String html_body = "<!DOCTYPE html>\r\n";
         html_body += "<html><head>\r\n";
         html_body += "<meta name='viewport' content='width=device-width, initial-scale=1, maximum-scale=1'>\r\n";
         html_body += "</head><body>\r\n";
         html_body += "<img id='pic_place' style='border-style:solid; transform:scale(1, 1);'>\r\n";
         html_body += "<div>\r\n";
         html_body += "<p><button style='border-radius:25px;' onclick='startStream()'>Start Stream</button>\r\n";
         html_body += "<button style='border-radius:25px;' onclick='changeCtrlCam(\"stop_stream\",0)'>Stop Stream</button>\r\n";
         html_body += "<button style='border-radius:25px;' onclick='stopStream()'>Window Stop</button></p>\r\n";
         html_body += "</div>\r\n";
         html_body += "<script>\r\n";
         html_body += "var base_url = document.location.origin;\r\n";
         html_body += "var url_stream = base_url + ':81';\r\n";
         html_body += "function startStream(){\r\n";
         html_body += "var pic = document.getElementById('pic_place');\r\n";
         html_body += "pic.src = url_stream+'/stream';\r\n";
         html_body += "changeCtrlCam('start_stream',0);};\r\n";
         html_body += "function stopStream(){\r\n";
         html_body += "window.stop();};\r\n";
         html_body += "function changeCtrlCam(id_txt, value_txt){\r\n";
         html_body += "var new_url = base_url+'/control?var=';\r\n";
         html_body += "new_url += id_txt + '&';\r\n";
         html_body += "new_url += 'val=' + value_txt;\r\n";
         html_body += "fetch(new_url)\r\n";
         html_body += ".then((response) => {\r\n";
         html_body += "if(response.ok){return response.text();} \r\n";
         html_body += "else {throw new Error();}})\r\n";
         html_body += ".then((text) => console.log(text))\r\n";
         html_body += ".catch((error) => console.log(error));};\r\n";
         html_body += "</script></body></html>\r\n\r\n";

    httpd_resp_set_type(req, "text/html");
    httpd_resp_set_hdr(req, "Accept-Charset", "UTF-8");
    return httpd_resp_send(req, html_body.c_str(), html_body.length());
}
//****************************************
static esp_err_t cmd_handler(httpd_req_t *req){
  char*  buf;
  size_t buf_len;
  char id_txt[32] = {0};
  char value_txt[32] = {0};

  buf_len = httpd_req_get_url_query_len(req) + 1;
  if (buf_len > 1) {
    buf = (char*)malloc(buf_len);
    if(!buf){
      httpd_resp_send_500(req);
      return ESP_FAIL;
    }
    if (httpd_req_get_url_query_str(req, buf, buf_len) == ESP_OK) {
      //Serial.println("-----Receive Control Command");
      //Serial.println(buf);
      if (httpd_query_key_value(buf, "var", id_txt, sizeof(id_txt)) == ESP_OK &&
        httpd_query_key_value(buf, "val", value_txt, sizeof(value_txt)) == ESP_OK) {
      } else {
        free(buf);
        httpd_resp_send_404(req);
        return ESP_FAIL;
      }
    } else {
      Serial.println(buf);
      free(buf);
      httpd_resp_send_404(req);
      return ESP_FAIL;
    }
    free(buf);
  } else {
    httpd_resp_send_404(req);
    return ESP_FAIL;
  }

  uint16_t val = atoi(value_txt);
  int res = 0;
  if(!strcmp(id_txt, "start_stream")){
    canSendImage = false;
    canStartStream = true;
    isCloseConnection = false;
  }else if(!strcmp(id_txt, "stop_stream")){
    canStartStream = false;
    isCloseConnection = true;
  }else if(!strcmp(id_txt, "reset")){
    ESP.restart(); //ESP32強制リセット
  }else if(!strcmp(id_txt, "ping80")){
    Serial.println("---------ping receive");
  }else{
    res = -1;
  }

  if(res){
    return httpd_resp_send_500(req);
  }

  httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
  return httpd_resp_send(req, NULL, 0);
}
//****************************************
static esp_err_t stream_handler(httpd_req_t *req){
  static char *stream_content_type = "multipart/x-mixed-replace;boundary=--myboundary";
  static char *stream_boundary = "\r\n--myboundary\r\n";
  static char *stream_part = "Content-Type: image/jpeg\r\nContent-Length: %u\r\n\r\n";

  camera_fb_t * fb = NULL;
  esp_err_t res = ESP_OK;
  char part_buf[64];
  uint8_t * jpg_buf = NULL;
  size_t jpg_buf_len = 0;

  res = httpd_resp_set_type(req, stream_content_type);
  if(res != ESP_OK){
    return res;
  }
  httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");

  while(true){
    if(isCloseConnection){
      Serial.println("Loop Out Streaming!");
      break;
    }

    fb = esp_camera_fb_get(); //イメージセンサOV2640から画像取得
    if (!fb) {
        Serial.println("Camera capture failed");
        res = ESP_FAIL;
    } else {
      jpg_buf = fb->buf;
      jpg_buf_len = fb->len;
      if(res == ESP_OK){
        fps_count++;
        res = httpd_resp_send_chunk(req, stream_boundary, strlen(stream_boundary));
      }else{
        Serial.printf("res3=%d\r\n", res);
      }
      if(res == ESP_OK){
        size_t hlen = snprintf((char *)part_buf, 64, stream_part, jpg_buf_len);
        res = httpd_resp_send_chunk(req, (const char *)part_buf, hlen);
      }else{
        Serial.printf("res1=%d\r\n", res);
        continue;
      }
      if(res == ESP_OK){
        if(jpg_buf_len){
          res = httpd_resp_send_chunk(req, (const char *)jpg_buf, jpg_buf_len);
        }else{
          Serial.println("Failed jpg_buf_len");
        }
      }else{
        Serial.printf("res2=%d\r\n", res);
      }

      if(fb){ //fbを使い回すために必要らしい
        esp_camera_fb_return(fb);
        fb = NULL;
        jpg_buf = NULL;
      } else if(jpg_buf){
        free(jpg_buf);
        jpg_buf = NULL;
      }
      res = ESP_OK;
    }
  }
  return res;
}

【ザッと簡単解説】

Arduino core for the ESP32以外の外部ライブラリは一切使いません。

●19行目:
イメージセンサOV2640からJPEG画像を取得するために、Arduino core for the ESP32同梱のesp_cameraライブラリをインクルードします。

●20行目:
JPEG画像をWiFiで送信するために、httpd関連ライブラリを使います。
その為には、esp_http_serverライブラリをインクルードします。

●25-40行:
M5Camera内にESP32-WROVERのピンアサイン設定です。
M5Cameraのモデルが旧版と最新版ではアサインが異なるので注意です。

●56-79行:
esp-cameraライブラリのconfig設定です。
76行目でイメージセンサOV2640からJPEG画像を取得する設定にしています。
また、77行目では、JPEG画質を40としています。
これは、JPEG画像サイズが2000byteを超えると、受信側M5StackのJPEGデコードでエラーになることが多くなるため、画質を低めに抑えています。
また、78行目で、JPEG画像の画角をQQVGA(160×120 pixel)としました。

●89-96行:
前回記事と同様で、イメージセンサOV2640の設定です。
91-92行は、M5Cameraのモデルによって設定し、画像の上下逆、左右反転します。

●98行目:
ここで、WiFiアクセスポイント(WiFiルーター)に接続します。

●102行目:
ここで、httpd関連関数を使って、WiFiサーバーを構築し、送信側(クライアント)からの接続待ち状態になります。

●136-171行:
ここは、サンプルスケッチCameraWebServerのapp_httpd.cppにあるものと殆ど同じです。
M5CameraのローカルIPアドレスが 192.168.0.11 と割り当てられた場合、ブラウザやM5Stackから以下のURL
http://192.168.0.11/
で送信すると、HTTPのGETメソッドが送られてきたと判断し、URLに /control とあればポート80番でコマンドが送信されたと判断し、ポート81番で/stream とあれば動画のストリーミングリクエストが送信されたということを判別するところです。

●173-209行:
ここは、URLのトップページのGETリクエストを受信した時にM5StackやブラウザにHTMLを返す関数です。
ブラウザからM5Cameraにアクセスがあった場合にHTMLを吐き出すので、その時に効果が分かると思います。

●211-269行:
ここは、ポート80番でクライアントからコントロールコマンドを受け取った時に行う処理を決める関数です。
M5Stackやブラウザのクライアント側のURLでは、ストリーミング停止については、
http://192.168.0.11/control?var=stop_stream&val=0
という形でコマンドコントロールクエリを送信して来るので、その文字列からコマンドを判別します。

●271-336行:
M5CameraのローカルIPアドレスが192.168.0.11の場合、M5Stackやブラウザからポート81番で以下のURL
http://192.168.0.11:81/stream
で送られてきた場合、294行のesp_camera_fb_get関数でイメージセンサOV2640からJPEG画像を取得し、chunk(チャンク)化したMJPEG(Motion JPEG)形式でWiFi送信しています。

1-2. M5Cameraスケッチをコンパイル書き込み実行

では、アクセスポイント(WiFiルーター)の電源を予め起動しておき、M5Cameraがルーターに接続できるようにファイアウォール等の設定を済ませておきます。

Arduino IDEの「ツール」のボード設定を以下のようにします。

(図01-02-01)

ボード:  ESP32 Wrover Module
Upload Speed:  921600
Flash Frequency:  80MHz
Flash Mode:  QIO
Partition Scheme:  Default 4MB width spiffs (1.2MB APP/1.5MB SPIFFS)
Core Debug Level:  なし
シリアルポート:  ※M5Cameraが接続してあるUSBポート

 

これで、シリアルモニタを115200bpsで起動し、コンパイル書き込み実行させます。
すると、WiFiルーター(アクセスポイント)に接続が成功すると、シリアルモニタには以下のように表示されます。

(図01-02-02)

そしたら、赤い枠線のところのようにM5CameraのローカルIPアドレスが表示されるので、それをメモっておき、後でM5Stack側のスケッチのhost名に書き込みます。

これで、M5Camera側の設定は完了です。

因みに、先ほど述べたように、M5CameraからのJPEG画像送信はchunk(チャンク)化したMJPEG(Motion JPEG)形式なので、パソコンのブラウザでもカメラ画像をストリーミングで見ることができます。

ブラウザのURL入力欄に先ほどのシリアルモニタに表示されたM5CameraのローカルIPアドレスを入力すると、下図の様に表示されます。

(図01-02-03)

そうしたら、「Start Stream」ボタンを押せば、下図の様に160×120 pixelの画像が表示されると思います。

(図01-02-04)

ストリーミング停止させる時には「Stop Stream」ボタンを押します。
続いてM5Stack等の別のデバイスで表示させたい時には、「Window Stop」ボタンを押せば、M5StackのLCDに表示させることができます。
M5StackのLCDに表示させるスケッチは次で紹介します。

1-3. M5Stack側のスケッチ入力

以前のこちらの記事では、JpgLoopAnimeを使って、WiFiで受信したJPEG画像を部分ごとにビットマップ画像に変換しながら同時にLCDに表示させていました。
今回は、それとは異なり、JPEG画像を完全にビットマップ画像に変換してからLCDに表示させる方法にします。
何故かと言うと、一旦1枚の画像を成立させないと、畳み込みニューラルネットワーク演算ができないからです。

そうすると、JPEGからビットマップに変換するライブラリが必要になります。
このブログでは以下の記事
Arduino core ESP32 でJPEGデコードおよびエンコードしてみた
で、Arduino core for the ESP32に同梱されているTJpgDecライブラリを使いましたが、今回、LovyanGFXの中でもTJpgDecライブラリが使われているため、それを利用させていただきます。
LovyanGFX内にはJPEGデータを単純にbyte型のRGB888ビットマップデータに変換するだけの関数が見つけられなかったので、TJpgDec Module Application Noteに習ってTJpgDecを使ってみました。

また、M5Stack公式ライブラリについてはボタン操作のみ使用します。

そんな感じでプログラミングしてみました。

(このソースコードに記載したWiFiアクセスポイントのssidやパスワードは、コンパイル後でもデバイスさえあればツールによって第三者が簡単に抜き取ることができます。充分注意してください。当方では一切責任を負いませんので、各個人がデバイスを厳重に管理することをお勧めします。)

/* The MIT License (MIT)
 * License URL: https://opensource.org/licenses/mit-license.php
 * Copyright (c) 2020 Mgo-tec. All rights reserved.
 *  
 * Use Arduino core for the ESP32 stable v1.0.4  
 * Use LovyanGFX library ver 0.3.4
 * Use M5Stack library ver 0.3.1
 */

#include <M5Stack.h>
#include <WiFi.h> 
#include <LovyanGFX.hpp>
#include <lgfx/utility/lgfx_tjpgd.h> //TJpgDec

static LGFX lcd;
using namespace std;

const char* ssid = "xxxxxxxxx"; //ご自分のルーターのSSIDに書き換えてください
const char* password = "xxxxxxxxx"; //ご自分のルーターのパスワードに書き換えてください
static const char* host = "192.168.0.11"; //M5Camera(相手先サーバー)のアドレス

static const uint16_t cam_flame_width = 160;
static const uint16_t cam_flame_height = 120;
static constexpr uint16_t bitmap_size = cam_flame_width * cam_flame_height * 3;

static enum SelCamCtrl {
  PING80 = 0,
  STOP_STREAM = 200, START_STREAM = 201, PAUSE_STREAM = 202,
  RESET_CAM = 255,
} SelectCamCtrl = PING80;

static enum StateStream{
  OFF_STREAM = 0, ON_STREAM, CLOSE_CONNECTION
} StatusStream = OFF_STREAM;

typedef struct {
  uint32_t ping80_lasttime = 0;
  bool isWiFiConnected = false;
  bool isPort80Handshake = false;
  bool isPort81Handshake = false;
  bool canSendCamCtrl = false;
  bool hasReceiveJpg = false;
} wifi_state_t;

typedef struct {
  uint16_t cam_width = cam_flame_width;
  uint16_t cam_height = cam_flame_height;
  int margin_cnn_x = 24;
  int margin_cnn_y = 4;
  int start_ix =  (margin_cnn_x - 1) * 3;
  int end_ix = (cam_flame_width * 3) - start_ix;
  int end_iy = cam_flame_height - (margin_cnn_y - 1);
  uint16_t max_x_inbuf = cam_flame_width * 3;
  uint16_t max_y_inbuf = cam_flame_height;
}disp_t;

typedef struct {
  uint32_t jpg_len = 0;
  uint8_t *buf = NULL;
  uint32_t buf_seek = 0;
} jpg_buf_t;

typedef struct {
    byte buf[bitmap_size] = {0};
    uint16_t w_bmp = cam_flame_width;
    uint16_t h_bmp = cam_flame_height;
} bmp_buf_t;

static bmp_buf_t bmpt;
static disp_t dispt;
static jpg_buf_t jpgt;
static wifi_state_t wst;

static uint32_t send_ping_time = 180000;

//********CPU core 1 task********************
void setup(){
  M5.begin();
  lcd.begin();
  lcd.setRotation(0);
  if (lcd.width() < lcd.height())
    lcd.setRotation(1);
  lcd.setFont(&fonts::Font2);
  lcd.println("WiFi begin.");
  lcd.setSwapBytes(false); // バイト順変換

  TaskHandle_t taskClientCtrl_handl, taskClientStrm_handl;
  xTaskCreatePinnedToCore(&taskClientControl, "taskClientControl", 4096, NULL, 5, &taskClientCtrl_handl, 1);
  xTaskCreatePinnedToCore(&taskClientStream, "taskClientStream", 8192, NULL, 7, &taskClientStrm_handl, 0);

  while(!wst.isWiFiConnected){
    Serial.print('.');
    delay(500);
  }

  lcd.println("WiFi connected! OK!");

  while(!wst.isPort81Handshake){ //ESP32サーバーとのMJPEGハンドシェイクが終わるまで待つ
    M5.update();
    if (M5.BtnA.wasReleased()) {
      changeStateStreamBtnDisp();
    }
    delay(1);
  }
}

void loop(){
  static uint32_t now_fps_time = 0;
  static uint8_t fps_count = 0;

  if(wst.hasReceiveJpg){
    bool isOkConvert = false;
    //JPEG画像をrgb888に変換.
    isOkConvert = decodeJPG(jpgt, bmpt);
    if(jpgt.buf){
      free(jpgt.buf);
      jpgt.buf = NULL;
    }
    lcd.pushImage(0, 0, 160, 120, (void*)bmpt.buf);
    fps_count++;
    wst.hasReceiveJpg = false;
    jpgt.jpg_len = 0;
  }

  if(StatusStream == ON_STREAM){
    if(millis() - now_fps_time > 1000UL){
      Serial.printf("%d (fps)\r\n", fps_count);
      fps_count = 0;
      now_fps_time = millis();
    }
  }

  M5.update();
  if (M5.BtnA.wasReleased() || M5.BtnA.pressedFor(1000, 200)) {
    Serial.println("Button A was Released");
    changeStateStreamBtnDisp();
  }
  if (M5.BtnB.wasReleased() || M5.BtnB.pressedFor(1000, 200)) {
    Serial.println("Button B was Released");
  }
  if (M5.BtnC.wasReleased()) {
    Serial.println("Button C was Released");
  }else if (M5.BtnC.wasReleasefor(500)) {
    SelectCamCtrl = RESET_CAM;
    wst.canSendCamCtrl = true;
    Serial.println("Send [Reset]!!!");
  }
}
//********CPU core 1 task********************
void taskClientControl(void *pvParameters){
  connectToWiFi();
  while(!wst.isWiFiConnected){
    delay(1);
  }
  while(true){
    //Serial.println("80");
    WiFiClient client80;
    connectClient80(client80);
    if (client80) {
      Serial.println("new client80");
      String get_request = "GET / HTTP/1.1\r\n";
      get_request += "Host: " + String(host);
      get_request += "\r\n";
      get_request += "Connection: keep-alive\r\n\r\n";

      while (client80.connected()){
        client80.print(get_request);
        get_request = "";
        String req_str;
        while(true){
          if(client80.available()){
            req_str =client80.readStringUntil('\n');
            if(req_str.indexOf("200 OK") > 0){
              Serial.println(req_str);
              req_str = "";
              if(!receiveToBlankLine(client80)){
                delay(1); continue;
              }
              Serial.println("complete server responce!");
              Serial.println("Go stream!");
              wst.isPort80Handshake = true;
              wst.ping80_lasttime = millis();
              while(true){
                changeCamControl(client80);
                sendPing80(client80, send_ping_time);
                while(client80.available()){
                  Serial.write(client80.read()); //これは必要。これが無いとコマンドを送信できない。
                  delay(1);
                }
                if(!wst.isPort80Handshake) break;
                delay(1);
              }
              goto exit_1;
            }
          }
          delay(1);
        }
        delay(1);
      }
    }
exit_1:
    while(client80.available()){
      Serial.write(client80.read());
      delay(1);
    }
    Serial.println("!!!!!!!!!!!!!!!!!!!! client80.stop");
    delay(10);
    client80.stop();
    client80.flush();
    delay(10);
  }
}
//********CPU core 0 task********************
void taskClientStream(void *pvParameters){
  while(true){
    //Serial.println("81");
    if(wst.isPort80Handshake){
      WiFiClient client81;
      if(!connectStreamClient(client81)){
        wst.canSendCamCtrl = true;
        Serial.println("Loop Out... wait client81 available");
        delay(10);
        client81.stop();
        client81.flush();
        Serial.println("!!!!!!!!!!!!!!!!!!!!! client81.stop");
        delay(10);
        SelectCamCtrl = PING80;
        wst.isPort80Handshake = false;
        StatusStream = CLOSE_CONNECTION;
      }
    }
    delay(1);
  }
}
//********************************************
bool connectStreamClient(WiFiClient &client81){
  int stream_port = 81;
  while(true){
    if (!client81.connect(host, stream_port)) {
      Serial.print(',');
    }else{
      Serial.printf("client81.connected(2) %s\r\n", host);
      break;
    }
    delay(500);
  }

  String get_request = "GET /stream HTTP/1.1\r\n";
  get_request += "Host: " + String(host);
  get_request += ":81\r\n";
  get_request += "Connection: keep-alive\r\n\r\n";

  client81.print(get_request);
  get_request = "";
  if(!receiveToBlankLine(client81)){
    Serial.println("connectStreamClient FALSE");
    return false;
  }
  Serial.println("stream header receive OK!");
  wst.isPort81Handshake = true;
  StatusStream = ON_STREAM;
  return receiveStream(client81);
}
//*********************************************
bool receiveStream(WiFiClient &client81){
  uint32_t time_out;
  uint32_t tmp_jpg_len = 0;

  while(StatusStream == ON_STREAM){
jpg_rcv0:
    if(!receiveBoundary(client81)){
      delay(1); continue;
    }
    if(client81.available()){
      time_out = millis();
      char tmp_cstr[5] = {};
      while(StatusStream == ON_STREAM){
        if(client81.available()){
          if((char)client81.read() == '\n') {
            client81.read((uint8_t *)tmp_cstr, 4);
            if(!strcmp(tmp_cstr, "\r\n\r\n")){
              String res_str = client81.readStringUntil('\n');

              while(wst.hasReceiveJpg){ //安定して受信するために必要なループ
                delay(1);
              }

              tmp_jpg_len = strtol(res_str.c_str(), NULL, 16);
              if(tmp_jpg_len) {
                //Serial.printf("tmp_jpg_len=%d\r\n", tmp_jpg_len);
                if(!receiveJpgData(client81, tmp_jpg_len)) {
                  Serial.println("receiveJpgData false");
                }
                time_out = millis();
              }
              delay(1);
              goto jpg_rcv0;
            }
          }
        }

        if(millis() - time_out > 1000UL){
          receiveBoundary(client81);
          time_out = millis();
          delay(1);
        }
      }
    }
    delay(1);
  }
  return false;
}
//********************************************
bool receiveJpgData(WiFiClient &client81, uint32_t tmp_jpg_len){
  if(!wst.hasReceiveJpg){
    if(wst.isPort81Handshake){
      while(StatusStream == ON_STREAM){
        if(client81.available()) {   
          //Serial.printf("before receive jpg heap=%d\r\n", esp_get_free_heap_size());
          jpgt.jpg_len = tmp_jpg_len;
          if(jpgt.buf) {
            free(jpgt.buf);
            jpgt.buf = NULL;
          }
          jpgt.buf = (uint8_t *)malloc(sizeof(uint8_t) * jpgt.jpg_len);

          uint32_t ptr_addrs = 0;          
          int32_t remain_bytes = jpgt.jpg_len;
          int tmp = 0;

          //Serial.printf("tmp_jpg_len=%d\r\n", tmp_jpg_len);
          while(tmp_jpg_len > 0){
            if(StatusStream != ON_STREAM) return false;
            if(client81.available()) {
              tmp = client81.read(&(jpgt.buf[ptr_addrs]), remain_bytes);
              if(tmp < 1){
                delay(1); continue;
              }
              ptr_addrs += tmp;
              remain_bytes = remain_bytes - tmp;
              if(remain_bytes <= 0) break;
            }else{
              return false;
            }
            delay(1);
          }
          if(jpgt.jpg_len) {
            wst.hasReceiveJpg = true;
          }
          return true;
        }
        delay(1);
      }
    }
  }
  return false;
}
//********************************************
bool receiveBoundary(WiFiClient &client81){
  char cstr[16] = {};
  uint32_t time_over = millis();
  while(StatusStream == ON_STREAM){
    if(client81.available()){
      if((char)client81.read() == '-'){
        if(client81.read((uint8_t*)cstr, 15)){
          if(strcmp(cstr, "-myboundary\r\n\r\n") == 0){
            return true;
          }
        }
      }
    }
    if(millis() - time_over > 1000UL) {
      time_over = millis();
      delay(1);
    }
    delay(1);
  }
  return false;
}
//********************************************
void connectClient80(WiFiClient &client80){
  int httpPort = 80;
  while(true){
    if(SelectCamCtrl == START_STREAM){
      if (client80.connect(host, httpPort)) {
        Serial.printf("client80.connected! %s\r\n", host);
        break;
      }else{
        Serial.print(',');
      }
    }
    delay(1);
  }
}
//********************************************
void sendPing80(WiFiClient &client80, uint32_t interval_time){
  if((millis() - wst.ping80_lasttime) > interval_time){
    sendCamCtrlRequest(client80, "ping80", "0");
    wst.ping80_lasttime = millis();
  }
}
//********************************************
void changeCamControl(WiFiClient &client80){
  if(wst.canSendCamCtrl){
    String id_str = "", val_str = "0";
    switch(SelectCamCtrl){
      case STOP_STREAM:
        id_str = "stop_stream";
        StatusStream = OFF_STREAM;
        wst.isPort80Handshake = false;
        break;
      case START_STREAM:
        id_str = "start_stream";
        break;
      case RESET_CAM:
        id_str = "reset";
        break;
      default:
        id_str = "ping80";
        break;
    }
    sendCamCtrlRequest(client80, id_str, val_str);
    SelectCamCtrl = PING80;
  }
}
//********************************************
void sendCamCtrlRequest(WiFiClient &client80, String id_str, String val_str){
  String get_request = "GET /control?var=" + id_str;
  get_request += "&val=" + val_str;
  get_request += " HTTP/1.1\r\n";
  get_request += "Host: " + String(host);
  get_request += "\r\nConnection: keep-alive\r\n\r\n";
  client80.print(get_request);
  Serial.print(get_request);
  get_request = "";
  id_str = "";
  val_str = "";
  receiveToBlankLine(client80);
  wst.canSendCamCtrl = false;
}
//*********************************************
bool receiveToBlankLine(WiFiClient &client){
  String req_str = "";
  uint32_t time_out = millis();
  while(StatusStream == ON_STREAM){
    if(client.available()){
      req_str =client.readStringUntil('\n');
      if(req_str.indexOf("\r") == 0) return true;
    }
    if(millis() - time_out > 10000) {
      Serial.println("--------error Time OUT receiveToBlankLine");
      break;
    }
    delay(1);
  }
  return false;
}
//********************************************
void connectToWiFi(){
  Serial.println("Connecting to WiFi network: " + String(ssid));
  WiFi.disconnect(true, true);
  delay(1000);
  WiFi.onEvent(WiFiEvent);
  WiFi.begin(ssid, password);
  Serial.println("Waiting for WIFI connection...");
}

void WiFiEvent(WiFiEvent_t event){
  switch(event) {
    case SYSTEM_EVENT_STA_GOT_IP:
      Serial.println("WiFi connected!");
      Serial.print("My IP address: ");
      Serial.println(WiFi.localIP());
      delay(1000);
      wst.isWiFiConnected = true;
      break;
    case SYSTEM_EVENT_STA_DISCONNECTED:
      Serial.println("WiFi lost connection");
      wst.isWiFiConnected = false;
      break;
    default:
      break;
  }
}
//********************************************
void changeStateStreamBtnDisp(){
  if(StatusStream == OFF_STREAM || StatusStream == CLOSE_CONNECTION){
    SelectCamCtrl = START_STREAM;
    StatusStream = ON_STREAM;
  }else if(StatusStream == ON_STREAM){
    SelectCamCtrl = STOP_STREAM;
    StatusStream = CLOSE_CONNECTION;
  }
  wst.canSendCamCtrl = true;
  Serial.printf("SelCamCtrl=%d\r\n", (uint8_t)SelectCamCtrl);
}
//********************************************
bool decodeJPG(jpg_buf_t &jpgt, bmp_buf_t &bmpt){
  void *work = (void*)malloc(3100);
  lgfxJdec jdec;
  JRESULT res;
  bool ret = false;

  res = lgfx_jd_prepare(&jdec, in_func, work, 3100, &bmpt);
  if (res == JDR_OK) {
    bmpt.w_bmp = jdec.width;

    res = lgfx_jd_decomp(&jdec, out_func, 0);
    if (res == JDR_OK) {
      ret = true;
    } else {
      Serial.printf("Failed to decompress: rc=%d\r\n", res);
      ret = false;
    }
  } else {
    Serial.printf("Failed to prepare: rc=%d\r\n", res);
    ret = false;
  }
  jpgt.buf_seek = 0;
  free(work);
  work = NULL;
  if(jpgt.buf) {
    free(jpgt.buf);
    jpgt.buf = NULL;
  }
  jpgt.jpg_len = 0;
  return ret;
}

uint32_t in_func (lgfxJdec* jd, uint8_t* buff, uint32_t nbyte){
  if (buff) {
    memcpy(buff, jpgt.buf + jpgt.buf_seek, nbyte);
    jpgt.buf_seek += nbyte;
    return nbyte;
  }else{
    jpgt.buf_seek += nbyte;
    return nbyte;
  }
}

uint32_t out_func (lgfxJdec* jd, void* bitmap, JRECT* rect){
  bmp_buf_t *dev = (bmp_buf_t*)jd->device;
  uint8_t *src, *dst;
  uint16_t y, bws, bwd;
  //RGB888
  src = (uint8_t*)bitmap;
  dst = dev->buf + 3 * (rect->top * dev->w_bmp + rect->left);
  bws = 3 * (rect->right - rect->left + 1);
  bwd = 3 * dev->w_bmp;
  for (y = rect->top; y <= rect->bottom; y++) {
      memcpy(dst, src, bws);
      src += bws; dst += bwd;
  }
  return 1;
}

【ザッと簡単解説】

●10行目:
ボタン操作だけのために、M5Stack公式ライブラリをインクルードします。

●12行目:
LCD(液晶ディスプレイ)表示のために、LovyanGFXをインクルードします。

●13行目:
JPEG画像をビットマップにデコードするためのTJpgDecをインクルードします。

●20行目:
ここは、送信側M5CameraのローカルIPアドレスに書き換えます。
1-1節のソースコードをコンパイル書き込みして、シリアルモニタに表示されたローカルIPアドレスを入力します。
ここでは仮のIPアドレスを入れています。

●63-67行:
ここの構造体でJPEGデコードしたビットマップデータを格納する配列を定義しています。
サイズは、160×120×3 = 57600 byte と膨大ですが、グローバル領域で定義すればギリギリ大丈夫なようです。
メインloop内で定義してしまうと、8192byteを超えてスタックオーバーフローしてしまうので注意です。

●78-85行:
M5Stack公式ライブラリとLovyanGFXライブラリの初期化です。
85行目のsetSwapBytes関数は、私の手持ちのM5CameraとM5Stackの組み合わせでは、赤色が青色に表示されてしまったので、falseにしました。たぶん、RGBかBGRの変換だと思います。

●87-89行:
WiFi通信のポート80番の制御コマンドコントロールと、81番の動画ストリーミングとをマルチタスク分けしました。
ここで、ポート80番通信をCPUコア1、ポート81番をCPUコア0としています。そして、タスク優先順位をそれぞれ5と7としています。
この各値を調節すると、もしかしたら途中で通信が途切れる問題が解決できるかもしれませんが、今のところ未解決です。

●98-104行:
M5StackのAボタンが押されて、M5Cameraと動画ストリーミング用ポート81番のハンドシェイク(コネクション確立)ができるまで待ちます。

●111-123行:
ここで、M5CameraからのJPEG画像受信が完了したら、RGB888ビットマップデータに変換して、LovyanGFXでLCD(液晶ディスプレイ)に表示させます。

●133-147行:
M5Stack公式ライブラリで、ボタン操作を処理しています。
Aボタン(左側ボタン)を押したらストリーミングがスタートします。

●150-212行:
メインloop関数はESP32のCPUコア1ですが、そこと同様のCPUコア1でマルチタスクでコマンド制御しています。
コマンドと言っても、Aボタンを押したらM5Cameraの動画ストリーミングをスタートおよびストップさせるだけのことです。
これはポート80番でWiFi通信します。

●214-379行:
ESP32のCPUコア0、ポート81番で動画ストリーミング通信しています。
要するに、M5CameraからTCPでchunk(チャンク)化したMJPEG(Motion JPEG)形式でJPEG画像を1秒間に25枚送って来ます。
そこからJPEG画像データを抽出しています。

●381-440行:
ここで、ポート80番で制御コマンド送受信させています。

●498-555行:
JPEG画像データをbyte型のRGB888フォーマットのビットマップデータに変換(デコード)しています。
LovyanGFXライブラリ内にある、TJpgDecライブラリを使用しています。

1-4. M5Stackスケッチのコンパイル書き込み実行

では、WiFiルーター(アクセスポイント)にM5Stackが接続できるようにファイアウォール等の設定を済ませておきます。

そして、Arduino IDEの「ツール」のボード設定は以下のようにします。

(図01-04-01)

ボード:  M5Stack-Core-ESP32
Upload Speed:  921600
Flash Frequency:  80MHz
Flash Mode:  QIO
Partition Scheme:  初期値
Core Debug Level:  なし
シリアルポート: ※M5Stackが接続してあるUSBポート

 

では、コンパイル書き込み実行させます。
すると、WiFiルーター(アクセスポイント)に接続成功すると、M5Stackのディスプレイに下図の様に表示されます。

(図01-04-02)

そうしたら、一番左側のボタンを押すと、下図の様にM5Cameraのストリーミング映像が表示されると思います。

(図01-04-03)

大体、平均25fpsでストリーミングできると思います。
ただ、ときどき通信が止まります。
この原因は未だ謎です。
どなたか解る方がいたら教えてください。

ここまでできたら、このスケッチに加えて画像の前処理を施して28×28 pixelに圧縮して、畳み込みニューラルネットワーク(CNN)演算をさせるだけです。

2.M5Stackスケッチに畳み込みニューラルネットワーク(CNN)を組み込む

では、1-3節で紹介したM5Stack側スケッチに、独自の畳み込みニューラルネットワーク(CNN)を組み込んでみます。

畳み込みニューラルネットワーク(CNN)のプログラミングは、前回記事とほぼ同じです。
ただ、少々コードを整形しました。

ここでのポイントは、JPEGデコードした160×120 pixelのRGB888ビットマップデータから112×112 pixelを切り取り、その画像を白黒反転させて、MaxPooling処理で圧縮して28×28 pixelにすることです。
そして、M5StackのBボタン(中央のボタン)を押すと、28×28 pixel画像をLCD(液晶ディスプレイ)に表示させていることです。
それは等倍サイズだとあまりにも小さいので、3倍に拡大表示させています。
こうすることによって、ターゲットの手書き数字を狙い易くできますし、照明による影の具合が分かり易くなりました。

そして、画像圧縮などの前処理も含めた畳み込みニューラルネットワーク(CNN)の計算時間は33msほどでしたが、送られてきた画像の全フレームに対してCNN処理させてしまうと、リアルタイム画像表示が遅れてしまうことが分かりました。
つまり、1フレームについてCNN処理が33msかかるとすると、25fpsの場合、
33×25 = 825ms
となってしまいます。
すると、ターゲットの手書き文字にM5Cameraを合わせようとすると、LCD(液晶ディスプレイ)表示が約1秒弱遅れて、合わせにくくなってしまいます。

いろいろ実験した結果、CNNによる手書き文字判定は、全フレームにCNNを掛ける必要は無いことが分かりました。
つまり、人間である自分自身が「リアルタイム画像判定できているぞ!」と判断できれば充分なので、200ms間隔(1秒間に5回)程度で判定が追従できていれば充分でした。
これで、LCD表示のリアルタイム性も損なわれずに表示できたと思います。

2-1. M5Stack側のスケッチにCNNの重みとバイアスデータを取り込む

では、1-3節で入力したM5Stack側のスケッチに畳み込みニューラルネットワークの重みとバイアスデータを取り込みます。

前回の記事を参照して、手書き数字MNISTデータセットをSONY Neural Network Console で学習させた重みとバイアスデータをエクスポートし、M5Stack側のArduinoスケッチフォルダに取り込んでおきます。
MainRuntime_parameters.h
MainRuntime_parameters.c
という2つのファイルです。

とりあえず、私が学習させたデータはGitHubに置いておきますので、参考にしてみて下さい。

https://github.com/mgo-tec/test_excel_deeplearning/tree/master/ESP32_CNN

2-2. M5Stack側のスケッチ入力

MainRuntime_parameters.h
MainRuntime_parameters.c
という2つのファイルを取り込んだら、スケッチを入力していきます。

(このソースコードに記載したWiFiアクセスポイントのssidやパスワードは、コンパイル後でもデバイスさえあればツールによって第三者が簡単に抜き取ることができます。充分注意してください。当方では一切責任を負いませんので、各個人がデバイスを厳重に管理することをお勧めします。)

/* The MIT License (MIT)
 * License URL: https://opensource.org/licenses/mit-license.php
 * Copyright (c) 2020 Mgo-tec. All rights reserved.
 *  
 * Use Arduino core for the ESP32 stable v1.0.4  
 * Use LovyanGFX library ver 0.3.4
 * Use M5Stack library ver 0.3.1
 */

#include "MainRuntime_parameters.h"
#include <M5Stack.h>
#include <WiFi.h> 
#include <LovyanGFX.hpp>
#include <lgfx/utility/lgfx_tjpgd.h> //TJpgDec

static LGFX lcd;
using namespace std;

const char* ssid = "xxxxxxxxx"; //ご自分のルーターのSSIDに書き換えてください
const char* password = "xxxxxxxxx"; //ご自分のルーターのパスワードに書き換えてください
static const char* host = "192.168.0.11"; //M5Camera(相手先サーバー)のアドレス

static const uint16_t cam_flame_width = 160;
static const uint16_t cam_flame_height = 120;
static constexpr uint16_t bitmap_size = cam_flame_width * cam_flame_height * 3;

static enum SelCamCtrl {
  PING80 = 0,
  STOP_STREAM = 200, START_STREAM = 201, PAUSE_STREAM = 202,
  RESET_CAM = 255,
} SelectCamCtrl = PING80;

static enum StateStream{
  OFF_STREAM = 0, ON_STREAM, CLOSE_CONNECTION
} StatusStream = OFF_STREAM;

typedef struct {
  uint32_t ping80_lasttime = 0;
  bool isWiFiConnected = false;
  bool isPort80Handshake = false;
  bool isPort81Handshake = false;
  bool canSendCamCtrl = false;
  bool hasReceiveJpg = false;
} wifi_state_t;

typedef struct {
  uint16_t cam_width = cam_flame_width;
  uint16_t cam_height = cam_flame_height;
  int margin_cnn_x = 24;
  int margin_cnn_y = 4;
  int start_ix =  (margin_cnn_x - 1) * 3;
  int end_ix = (cam_flame_width * 3) - start_ix;
  int end_iy = cam_flame_height - (margin_cnn_y - 1);
  uint16_t max_x_inbuf = cam_flame_width * 3;
  uint16_t max_y_inbuf = cam_flame_height;
  uint16_t cam_in_pix_width = 112;
  uint16_t pre_maxpool_width = 28;
}disp_t;

class Conv {
  public:
    uint16_t input_vec_width;
    uint16_t output_vec_width;
    uint16_t output_padding;
    uint16_t output_width_pad;
    uint16_t kernel_width;
    uint16_t kernel_size;
    uint16_t output_size;
    uint32_t w_addrs;
    uint32_t b_addrs;
    uint8_t input_node_num;
    uint8_t output_node_num;
    uint8_t stride;
    float *weight;
    float *bias;
};

class MaxPooling {
  public:
    uint16_t input_vec_width;
    uint16_t output_vec_width;
    uint16_t output_padding;
    uint16_t output_width_pad;
    uint16_t kernel_width;
    uint16_t output_size;
    uint8_t stride;
    uint8_t output_node_num;
};

class Affine {
  public:
    uint16_t weight_size;
    uint16_t output_node_num;
    float *weight;
    float *bias;
};

typedef struct {
  uint32_t jpg_len = 0;
  uint8_t *buf = NULL;
  uint32_t buf_seek = 0;
} jpg_buf_t;

typedef struct {
    byte buf[bitmap_size] = {0};
    uint16_t w_bmp = cam_flame_width;
    uint16_t h_bmp = cam_flame_height;
} bmp_buf_t;

static bmp_buf_t bmpt;
static disp_t dispt;
static jpg_buf_t jpgt;
static wifi_state_t wst;

static Conv fcnv, cnv2, lcnv;
static MaxPooling pre_mxp, mxp1, mxp2;
static Affine afn1;

static bool onDispPreMaxpl = false;
static constexpr float byt_to_flt = 1.0f / 255.0f;
static uint32_t send_ping_time = 180000;
static uint32_t interval_do_cnn = 200; //cnn実行時間間隔200ms

//********CPU core 1 task********************
void setup(){
  //事前画像処理maxpoolingパラメータ初期化
  pre_mxp.input_vec_width = dispt.cam_in_pix_width;
  pre_mxp.output_vec_width = dispt.pre_maxpool_width;
  pre_mxp.output_padding = 0;
  pre_mxp.output_width_pad = pre_mxp.output_vec_width + pre_mxp.output_padding;
  pre_mxp.kernel_width = 4;
  pre_mxp.stride = 4;

  //初回convolutionパラメータ初期化
  fcnv.input_vec_width = dispt.pre_maxpool_width;
  fcnv.output_padding = 1;
  fcnv.stride = 1;
  fcnv.kernel_width = 4;
  fcnv.output_vec_width = 25;
  fcnv.output_width_pad = fcnv.output_vec_width + fcnv.output_padding;
  fcnv.kernel_size = fcnv.kernel_width * fcnv.kernel_width;
  fcnv.w_addrs = 0;
  fcnv.b_addrs = 0;
  fcnv.output_node_num =  5;
  fcnv.weight = (float *)MainRuntime_parameters[0];
  fcnv.bias = (float *)MainRuntime_parameters[1];
  fcnv.output_size = fcnv.output_width_pad * fcnv.output_width_pad;

  //初回maxpoolingパラメータ初期化
  mxp1.input_vec_width = fcnv.output_width_pad;
  mxp1.output_vec_width = 13;
  mxp1.output_padding = 0;
  mxp1.output_width_pad = mxp1.output_vec_width + mxp1.output_padding;
  mxp1.kernel_width = 2;
  mxp1.stride = 2;
  mxp1.output_node_num = fcnv.output_node_num;
  mxp1.output_size = mxp1.output_width_pad * mxp1.output_width_pad;

  //2回目convolutionパラメータ初期化
  cnv2.input_vec_width = mxp1.output_vec_width;
  cnv2.output_vec_width = 11;
  cnv2.output_padding = 1;
  cnv2.stride = 1;
  cnv2.output_width_pad = cnv2.output_vec_width + cnv2.output_padding;
  cnv2.kernel_width = 3;
  cnv2.kernel_size = cnv2.kernel_width * cnv2.kernel_width;
  cnv2.w_addrs = 0;
  cnv2.b_addrs = 0;
  cnv2.input_node_num = 5;
  cnv2.output_node_num = 3;
  cnv2.weight = (float *)MainRuntime_parameters[2];
  cnv2.bias = (float *)MainRuntime_parameters[3];
  cnv2.output_size = cnv2.output_width_pad * cnv2.output_width_pad;

  //2回目maxpoolingパラメータ初期化
  mxp2.input_vec_width = cnv2.output_width_pad;
  mxp2.output_vec_width = 6;
  mxp2.output_padding = 0;
  mxp2.output_width_pad = mxp2.output_vec_width + mxp2.output_padding;
  mxp2.kernel_width = 2;
  mxp2.stride = 2;
  mxp2.output_node_num = cnv2.output_node_num;
  mxp2.output_size = mxp2.output_width_pad * mxp2.output_width_pad;

  //最終段convolutionパラメータ初期化
  lcnv.output_vec_width = 3;
  lcnv.input_vec_width = 6;
  lcnv.output_padding = 0;
  lcnv.kernel_width = 4;
  lcnv.kernel_size = lcnv.kernel_width * lcnv.kernel_width;
  lcnv.stride = 1;
  lcnv.w_addrs = 0;
  lcnv.b_addrs = 0;
  lcnv.input_node_num = 3;
  lcnv.output_node_num = 3;
  lcnv.weight = (float *)MainRuntime_parameters[4];
  lcnv.bias = (float *)MainRuntime_parameters[5];
  lcnv.output_size = lcnv.output_node_num * lcnv.output_vec_width * lcnv.output_vec_width;

  //affineパラメータ初期化
  afn1.weight_size = lcnv.output_size;
  afn1.output_node_num = 10;
  afn1.weight = (float *)MainRuntime_parameters[6];
  afn1.bias = (float *)MainRuntime_parameters[7];

  M5.begin();

  lcd.begin();
  lcd.setRotation(0);
  if (lcd.width() < lcd.height())
    lcd.setRotation(1);
  lcd.setFont(&fonts::Font2);
  lcd.println("WiFi begin.");
  lcd.setSwapBytes(false); // バイト順変換

  TaskHandle_t taskClientCtrl_handl, taskClientStrm_handl;
  xTaskCreatePinnedToCore(&taskClientControl, "taskClientControl", 4096, NULL, 5, &taskClientCtrl_handl, 1);
  xTaskCreatePinnedToCore(&taskClientStream, "taskClientStream", 8192, NULL, 7, &taskClientStrm_handl, 0);

  while(!wst.isWiFiConnected){
    Serial.print('.');
    delay(500);
  }

  lcd.println("WiFi connected! OK!");

  while(!wst.isPort81Handshake){ //ESP32サーバーとのMJPEGハンドシェイクが終わるまで待つ
    M5.update();
    if (M5.BtnA.wasReleased()) {
      changeStateStreamBtnDisp();
    }
    delay(1);
  }

  lcd.startWrite();
  lcd.setCursor(165, 0);
  lcd.setTextSize(2);
  lcd.print("result= ");
  lcd.setCursor(218, 80);
  lcd.print(" fps");
  lcd.setCursor(160, 120);
  lcd.print("pCNN");
  lcd.setCursor(280, 120);
  lcd.print("ms");
  lcd.setCursor(160, 150);
  lcd.print("CNN");
  lcd.setCursor(280, 150);
  lcd.print("ms");
}

void loop(){
  static uint32_t now_fps_time = 0;
  static uint32_t exec_time = 0;
  static uint8_t fps_count = 0;

  if(wst.hasReceiveJpg){
    bool isOkConvert = false;
    //JPEG画像をrgb888に変換.
    isOkConvert = decodeJPG(jpgt, bmpt);
    if(jpgt.buf){
      free(jpgt.buf);
      jpgt.buf = NULL;
    }
    if(isOkConvert){
      //カメラ画像に緑線BOX表示
      dispImgPlusRect(bmpt.buf, dispt);
      //CNN計算判定(200ms毎)
      if(millis() - exec_time > interval_do_cnn){
        cnn(bmpt.buf, dispt);
        exec_time = millis();
      }
      fps_count++;
    }
    wst.hasReceiveJpg = false;
    jpgt.jpg_len = 0;
  }

  if(StatusStream == ON_STREAM){
    if(millis() - now_fps_time > 1000UL){
      Serial.printf("%d (fps)\r\n", fps_count);
      lcd.setCursor(175, 80);
      lcd.setTextSize(2);
      lcd.print(fps_count);
      fps_count = 0;
      now_fps_time = millis();
    }
  }

  M5.update();
  if (M5.BtnA.wasReleased() || M5.BtnA.pressedFor(1000, 200)) {
    Serial.println("Button A was Released");
    changeStateStreamBtnDisp();
  }
  if (M5.BtnB.wasReleased() || M5.BtnB.pressedFor(1000, 200)) {
    Serial.println("Button B was Released");
    if(onDispPreMaxpl){
      onDispPreMaxpl = false;
      lcd.fillRect(0, 120, 160, 120, (uint8_t)0x00);
    }else{
      onDispPreMaxpl = true;
    }
  }
  if (M5.BtnC.wasReleased()) {
    Serial.println("Button C was Released");
  }else if (M5.BtnC.wasReleasefor(500)) {
    SelectCamCtrl = RESET_CAM;
    wst.canSendCamCtrl = true;
    Serial.println("Send [Reset]!!!");
  }
}
//**********************************************
void dispImgPlusRect(uint8_t *input_buf, disp_t &dispt){
  int i = dispt.end_iy - 1;
  int tmp_i = 0;
  loopGreenHLine(input_buf, dispt.margin_cnn_y, dispt);
  loopGreenHLine(input_buf, dispt.end_iy - 1, dispt);
  do{
    tmp_i = i * dispt.max_x_inbuf;
    input_buf[dispt.start_ix + tmp_i] = 0x0; //red
    input_buf[dispt.start_ix + 1 + tmp_i] = 0xff; //green
    input_buf[dispt.start_ix + 2 + tmp_i] = 0x0; //blue
    input_buf[dispt.end_ix - 3 + tmp_i] = 0x0; //red
    input_buf[dispt.end_ix - 2 + tmp_i] = 0xff; //green
    input_buf[dispt.end_ix - 1 + tmp_i] = 0x0; //blje
    --i;
  }while(i >= dispt.margin_cnn_y);

  lcd.pushImage( 0, 0, 160, 120, (void*)input_buf);
}

void loopGreenHLine(uint8_t *buf, int j, disp_t &dispt){
  int i = dispt.end_ix - 2; //green画素から入力
  int tmp_j = dispt.max_x_inbuf * j;
  do{
    buf[i + tmp_j] = 0xff;
    i -= 3;
  }while(i >= dispt.start_ix);
}
//**********************************************
void cnn(uint8_t *input_buf, disp_t &dispt){
  uint32_t all_calc_time = millis(); //NNの全計算時間計測開始
  //160 x 120 pix画像から112 x 112 pix 画像を抽出し、白黒反転する
  vector<uint8_t> in_clip_vec(dispt.cam_in_pix_width * dispt.cam_in_pix_width, 0);
  imgPreprocessing(input_buf, in_clip_vec, dispt);

  //112x112pix画像を28x28pix画像にMaxPooling圧縮する
  vector<uint8_t> pre_maxpool_vec(dispt.pre_maxpool_width * dispt.pre_maxpool_width, 0);
  preImgMaxpool(in_clip_vec, pre_mxp, pre_maxpool_vec);

  if(onDispPreMaxpl){
    dispPreMpl(pre_maxpool_vec, dispt);
  }

  //ここから畳み込みニューラルネットワーク
  uint32_t cnn_calc_time = millis(); //CNNの計算時間計測開始
  //Serial.println("------------convolution1------------");
  vector<uint8_t>().swap(in_clip_vec); //vectorコンテナ解放
  vector<vector<float>> conv1_out_vec(fcnv.output_node_num, vector<float>(fcnv.output_size, -300.0f));
  firstConvolution(pre_maxpool_vec, fcnv, conv1_out_vec);

  //Serial.println("------------maxpooling1------------");
  vector<uint8_t>().swap(pre_maxpool_vec); //vectorコンテナ解放
  vector<vector<float>> maxpool1_out_vec(mxp1.output_node_num, vector<float>(mxp1.output_size, 0.0f));
  maxpooling(conv1_out_vec, mxp1, maxpool1_out_vec);

  //Serial.println("------------tanh1------------");
  for(int i = 0; i < mxp1.output_node_num; i++){
    vecTanhf(maxpool1_out_vec[i]);
  }

  //Serial.println("------------conv2----------");
  //この環境ではメモリ解放にshrink_to_fitは使えない
  vector<vector<float>>().swap(conv1_out_vec); //vectorコンテナ解放
  vector<vector<float>> conv2_out_vec(cnv2.output_node_num, vector<float>(cnv2.output_size, -300.0f));
  convolution(maxpool1_out_vec, cnv2, conv2_out_vec);

  //Serial.println("------------maxpooling2------------");
  vector<vector<float>>().swap(maxpool1_out_vec); //vectorコンテナ解放
  vector<vector<float>> maxpool2_out_vec(mxp2.output_node_num, vector<float>(mxp2.output_size, 0.0f));
  maxpooling(conv2_out_vec, mxp2, maxpool2_out_vec);

  //Serial.println("------------tanh2------------");
  for(int i = 0; i < mxp2.output_node_num; i++){
    vecTanhf(maxpool2_out_vec[i]);
  }

  //Serial.println("------------conv3----------");
  vector<vector<float>>().swap(conv2_out_vec); //vectorコンテナ解放
  vector<float> conv3_out_vec(lcnv.output_size, -300.0f);
  lastConvolution(maxpool2_out_vec, lcnv, conv3_out_vec);

  //Serial.println("------------tanh3------------");
  vecTanhf(conv3_out_vec);

  //Serial.println("------------affine----------");
  vector<vector<float>>().swap(maxpool2_out_vec); //vectorコンテナ解放
  vector<float> affine_out_vec(afn1.output_node_num, -300.0f);
  affine(conv3_out_vec, afn1, affine_out_vec);

  //Serial.println("------------softmax----------");
  vector<float>().swap(conv3_out_vec); //vectorコンテナ解放
  vector<float> softmax_out_vec(afn1.output_node_num, 0.0f);
  softmax(affine_out_vec, softmax_out_vec);

  vector<float>().swap(affine_out_vec); //vectorコンテナ解放
  vector<float>::iterator iter = max_element(softmax_out_vec.begin(), softmax_out_vec.end());
  uint8_t index = distance(softmax_out_vec.begin(), iter);
  cnn_calc_time = millis() - cnn_calc_time;
  all_calc_time = millis() - all_calc_time;

  lcd.setCursor(265, 0);
  lcd.setTextSize(4);
  lcd.print(index);
  lcd.setTextSize(2);
  lcd.setCursor(243, 120);
  lcd.print(all_calc_time);
  lcd.setCursor(243, 150);
  lcd.print(cnn_calc_time);
}
//************画像前処理****************************
void imgPreprocessing(uint8_t *input_pix, vector<uint8_t> &output_pix, disp_t &dispt){
  uint16_t ary_cnt = 0;
  uint8_t rgb_maxvalue = 0;
  uint8_t threshold = 160; //画素の有効閾値
  vector<uint8_t> rgb_vec(3, 0);

  int x_margin = dispt.margin_cnn_x - 1;
  int y_margin = dispt.margin_cnn_y - 1; 
  int y_elm = y_margin;
  const int imax = x_margin * 3 + dispt.cam_in_pix_width * 3;
  const int ary_max = dispt.cam_in_pix_width * dispt.cam_in_pix_width;
  int i;
  int j = dispt.cam_height + y_margin - 1; //160幅pixelの内、24~135のpixelを使用

  do{
    for(i = x_margin * 3; i < dispt.max_x_inbuf; i += 3){
      if(i >= imax) break;
      rgb_vec[0] = 0xff - input_pix[y_elm * dispt.max_x_inbuf + i]; //red
      rgb_vec[1] = 0xff - input_pix[y_elm * dispt.max_x_inbuf + (i+1)]; //green
      rgb_vec[2] = 0xff - input_pix[y_elm * dispt.max_x_inbuf + (i+2)]; //blue

      //RGBの値のうち、最大値を選択
      rgb_maxvalue = *max_element(rgb_vec.begin(), rgb_vec.end());

      if(rgb_maxvalue > threshold){
        output_pix[ary_cnt] = rgb_maxvalue;
      }else{
        output_pix[ary_cnt] = 0;
      }
      ary_cnt++;
      if(ary_cnt >= ary_max) return;
    }
    ++y_elm;
    --j;
  }while(j >= y_margin);
}

void preImgMaxpool(vector<uint8_t> &input_pix, MaxPooling mpl, vector<uint8_t> &output_vec){
  uint16_t vec_index = 0;
  uint16_t max_vec_count = mpl.input_vec_width - (mpl.kernel_width - 1);
  vector<uint8_t> in_kernel_vec(mpl.kernel_width * mpl.kernel_width, 0);

  int i, j;
  for(j = 0; j < max_vec_count; j = j + mpl.stride){
    for(i = 0; i < max_vec_count; i = i + mpl.stride){
      preOutputMaxElm(input_pix, in_kernel_vec, mpl, i, j, output_vec[vec_index]);
      vec_index++;
    }
  }
}

void preOutputMaxElm(vector<uint8_t> &input_pix, vector<uint8_t> &in_kernel_vec, MaxPooling &mpl, int x_elm, int y_elm, uint8_t &output){
  int ix;
  int iy = 0;
  uint16_t k_cnt = 0;
  int i, j;
  j = mpl.kernel_width - 1;
  do{
    ix = 0;
    i = mpl.kernel_width - 1;
    do{
      in_kernel_vec[k_cnt] = input_pix[ix + x_elm + (y_elm + iy) * mpl.input_vec_width];
      ++ix;
      --i;
    }while(i >= 0);
    ++iy;
    --j;
  }while(j >= 0);
  output = *max_element(in_kernel_vec.begin(), in_kernel_vec.end());
}

void dispPreMpl(vector<uint8_t> &inpix, disp_t &dispt){
  uint8_t disp_buf[dispt.pre_maxpool_width * dispt.pre_maxpool_width * 3] = {};
  int i;
  int j = dispt.pre_maxpool_width - 1;
  int in_ix = 0, out_ix = 0;
  uint8_t tmp = 0;
  do{
    i = dispt.pre_maxpool_width - 1;
    do{
      tmp = inpix[in_ix++];
      disp_buf[out_ix++] = tmp;
      disp_buf[out_ix++] = tmp;
      disp_buf[out_ix++] = tmp;
      --i;
    }while(i >= 0);
    --j;
  }while(j >= 0);
  //lcd.pushImage( 0, 130, 28, 28, (void*)disp_buf);
  lcd.pushImageRotateZoom(80, 180, 16, 16, 0, 3.0, 3.0, 28, 28, (void*)disp_buf);
}
//************CNN*************************
void firstConvolution(vector<uint8_t> &input_pix, Conv &conv, vector<vector<float>> &output_vec){
  int i;
  for(i = 0; i < conv.output_node_num; i++){
    conv.w_addrs = i * conv.kernel_size;
    conv.b_addrs = i;
    tmpFConv(input_pix, conv, output_vec[i]);
  }
}

void tmpFConv(vector<uint8_t> &input_pix, Conv &conv, vector<float> &output_vec){
  vector<float> weight_vec(conv.kernel_size, 0.0f);
  vector<float> in_kernel_vec(conv.kernel_size, 0.0f);

  weight_vec.assign(conv.weight + conv.w_addrs, conv.weight + conv.w_addrs + conv.kernel_size);

  uint16_t vec_index = 0;
  uint16_t max_vec_count = conv.input_vec_width - (conv.kernel_width - 1);
  float innr_prod = 0.0f;

  int i, j;
  for(j = 0; j < max_vec_count; j = j + conv.stride){
    for(i = 0; i < max_vec_count; i = i + conv.stride){
      outputInnerProdFConv(input_pix, weight_vec, in_kernel_vec, conv, i, j, innr_prod);
      output_vec[vec_index] = innr_prod + conv.bias[conv.b_addrs];
      vec_index++;
    }
    vec_index = vec_index + conv.output_padding;
  }
}

void outputInnerProdFConv(vector<uint8_t> &input_pix, vector<float> &weight_vec, vector<float> &in_kernel_vec, Conv &conv, int x_elm, int y_elm, float &innr_p){
  uint16_t vec_index = 0;
  int i, j;
  int ix;
  int iy = 0;
  j = conv.kernel_width - 1;
  do{
    ix = 0;
    i = conv.kernel_width - 1;
    do{
      in_kernel_vec[vec_index++] = (float)input_pix[ix + x_elm + ((iy + y_elm) * conv.input_vec_width)] * byt_to_flt;
      ++ix;
      --i;          
    }while(i >= 0);
    ++iy;
    --j;
  }while(j >= 0);

  innr_p = inner_product(weight_vec.begin(), weight_vec.end(), in_kernel_vec.begin(), 0.0f);
}

void convolution(vector<vector<float>> &input_vec, Conv &conv, vector<vector<float>> &output_vec){
  int i;
  for(i = 0; i < conv.output_node_num; i++){
    conv.w_addrs = i * (conv.kernel_size * conv.input_node_num);
    conv.b_addrs = i;
    tmpConv(input_vec, conv, output_vec[i]);
  }
}

void tmpConv(vector<vector<float>> &input_vec, Conv &conv, vector<float> &output_vec){
  vector<vector<float>> weight_vec(conv.input_node_num, vector<float>(conv.kernel_size, 0.0f));
  vector<vector<float>> in_kernel_vec(conv.input_node_num, vector<float>(conv.kernel_size, 0.0f));

  for(int i = conv.input_node_num - 1; i >= 0; --i){
    weight_vec[i].assign(conv.weight + conv.w_addrs + (i * conv.kernel_size), conv.weight + conv.w_addrs + ((i + 1)* conv.kernel_size));
  }

  uint16_t vec_index = 0;
  uint16_t max_vec_count = conv.input_vec_width - (conv.kernel_width - 1);
  float tmp1 = 0.0f;
  int i, j;
  for(j = 0; j < max_vec_count; j = j + conv.stride){
    for(i = 0; i < max_vec_count; i = i + conv.stride){
      tmp1 = 0.0f;
      outputInnerProdConv(input_vec, weight_vec, in_kernel_vec, conv, i, j, tmp1);
      output_vec[vec_index++] = tmp1 + conv.bias[conv.b_addrs];
    }
    vec_index = vec_index + conv.output_padding;
  }
}

void outputInnerProdConv(vector<vector<float>> &input_vec, vector<vector<float>> &weight_vec, vector<vector<float>> &in_kernel_vec, Conv &conv, int x_elm, int y_elm, float &innr_p){
  vector<float>::const_iterator itr;
  int cnt1;
  int cnt2 = 0;
  int i;
  int j = conv.input_node_num - 1;
  do{
    cnt1 = 0;
    i = conv.kernel_width - 1;
    do{
      itr = input_vec[cnt2].begin() + x_elm + ((cnt1 + y_elm) * conv.input_vec_width);
      copy(itr, itr + conv.kernel_width, in_kernel_vec[cnt2].begin() + (cnt1 * conv.kernel_width));
      ++cnt1;
      --i;
    }while(i >= 0);

    innr_p = innr_p + inner_product(weight_vec[cnt2].begin(), weight_vec[cnt2].end(), in_kernel_vec[cnt2].begin(), 0.0f);
    ++cnt2;
    --j;
  }while(j >= 0);
}

void vecTanhf(vector<float> &input_vec){
  for(float &x: input_vec){
    x = tanhf(x);
  }
}

void maxpooling(vector<vector<float>> &input_vec, MaxPooling &mpl, vector<vector<float>> &output_vec){
  int i;
  for(i = 0; i < mpl.output_node_num; i++){
    tmpMaxpool(input_vec[i], mpl, output_vec[i]);
  }
}

void tmpMaxpool(vector<float> &input_vec, MaxPooling &mpl, vector<float> &output_vec){
  uint16_t vec_index = 0;
  uint16_t max_vec_count = mpl.input_vec_width - (mpl.kernel_width - 1);
  vector<float> in_kernel_vec(mpl.kernel_width * mpl.kernel_width, -400.0f);
  int i, j;
  for(j = 0; j < max_vec_count; j = j + mpl.stride){
    for(i = 0; i < max_vec_count; i = i + mpl.stride){
      if(vec_index >= output_vec.size()) return;
      outputMaxElm(input_vec, in_kernel_vec, mpl, i, j, output_vec[vec_index]);
      vec_index++;
    }
  }
}

void outputMaxElm(vector<float> &input_vec, vector<float> &in_kernel_vec, MaxPooling &mpl, int x_elm, int y_elm, float &output){
  vector<float>::const_iterator itr;
  int iy = 0;
  int i = mpl.kernel_width - 1;
  do{
    itr = input_vec.begin() + x_elm + (y_elm + iy) * mpl.input_vec_width;
    copy(itr, itr + mpl.kernel_width, in_kernel_vec.begin() + (iy * mpl.kernel_width));
    ++iy;
    --i;
  }while(i >= 0);
  output = *max_element(in_kernel_vec.begin(), in_kernel_vec.end());
}

void lastConvolution(vector<vector<float>> &input_vec, Conv &conv, vector<float> &output_vec){
  vector<vector<float>> weight_vec(conv.input_node_num, vector<float>(conv.kernel_size, 0.0f));
  vector<vector<float>> in_kernel_vec(conv.input_node_num, vector<float>(conv.kernel_size, 0.0f));
  uint16_t vec_index = 0;
  int i, j, k;
  for(k = 0; k < conv.output_node_num; k++){
    conv.w_addrs = k * (conv.kernel_size * conv.input_node_num);
    conv.b_addrs = k;
    float tmp1 = 0.0f;
    for(i = conv.input_node_num - 1; i >= 0; --i){
      weight_vec[i].assign(conv.weight + conv.w_addrs + (i * conv.kernel_size), conv.weight + conv.w_addrs + ((i + 1)* conv.kernel_size));
    }
    for(j = 0; j < conv.output_vec_width; j = j + conv.stride){
      for(i = 0; i < conv.output_vec_width; i = i + conv.stride){
        outputInnerProdConv(input_vec, weight_vec, in_kernel_vec, conv, i, j, tmp1);
        output_vec[vec_index++] = tmp1 + conv.bias[conv.b_addrs];
        tmp1 = 0.0f;
      }
    }
  }
}

void affine(vector<float> &input_vec, Affine &affine, vector<float> &output_vec){
  vector<float> weight_vec(affine.weight_size, 0.0f);
  int i = affine.output_node_num - 1;
  do{
    weight_vec.assign(affine.weight + (affine.weight_size * i), affine.weight + (affine.weight_size * i) + affine.weight_size);
    output_vec[i] = inner_product(weight_vec.begin(), weight_vec.end(), input_vec.begin(), 0.0f) + affine.bias[i];
    --i;
  }while(i >= 0);
}

void softmax(vector<float> &input_vec, vector<float> &output_vec){
  float sum_exp = 0.0f;
  float tmp[input_vec.size()];
  int i = input_vec.size() - 1;
  do{
    tmp[i] = expf(input_vec[i]);
    sum_exp += tmp[i];
    --i;
  }while(i >= 0);

  i = input_vec.size() - 1;
  do{
    if(sum_exp == 0.0f){
      Serial.println("Error! sum_exp.");
    }
    output_vec[i] = tmp[i] / sum_exp;
    --i;
  }while(i >= 0);
}
//********CPU core 0 task********************
void taskClientControl(void *pvParameters){
  connectToWiFi();
  while(!wst.isWiFiConnected){
    delay(1);
  }
  while(true){
    //Serial.println("80");
    WiFiClient client80;
    connectClient80(client80);
    if (client80) {
      Serial.println("new client80");
      String get_request = "GET / HTTP/1.1\r\n";
      get_request += "Host: " + String(host);
      get_request += "\r\n";
      get_request += "Connection: keep-alive\r\n\r\n";

      while (client80.connected()){
        client80.print(get_request);
        get_request = "";
        String req_str;
        while(true){
          if(client80.available()){
            req_str =client80.readStringUntil('\n');
            if(req_str.indexOf("200 OK") > 0){
              Serial.println(req_str);
              req_str = "";
              if(!receiveToBlankLine(client80)){
                delay(1); continue;
              }
              Serial.println("complete server responce!");
              Serial.println("Go stream!");
              wst.isPort80Handshake = true;
              wst.ping80_lasttime = millis();
              while(true){
                changeCamControl(client80);
                sendPing80(client80, send_ping_time);
                while(client80.available()){
                  Serial.write(client80.read()); //これは必要。これが無いとコマンドを送信できない。
                  delay(1);
                }
                if(!wst.isPort80Handshake) break;
                delay(1);
              }
              goto exit_1;
            }
          }
          delay(1);
        }
        delay(1);
      }
    }
exit_1:
    while(client80.available()){
      Serial.write(client80.read());
      delay(1);
    }
    Serial.println("!!!!!!!!!!!!!!!!!!!! client80.stop");
    delay(10);
    client80.stop();
    client80.flush();
    delay(10);
  }
}
//********CPU core 0 task********************
void taskClientStream(void *pvParameters){
  while(true){
    //Serial.println("81");
    if(wst.isPort80Handshake){
      WiFiClient client81;
      if(!connectStreamClient(client81)){
        wst.canSendCamCtrl = true;
        Serial.println("Loop Out... wait client81 available");
        delay(10);
        client81.stop();
        client81.flush();
        Serial.println("!!!!!!!!!!!!!!!!!!!!! client81.stop");
        delay(10);
        SelectCamCtrl = PING80;
        wst.isPort80Handshake = false;
        StatusStream = CLOSE_CONNECTION;
      }
    }
    delay(1);
  }
}
//********************************************
bool connectStreamClient(WiFiClient &client81){
  int stream_port = 81;
  while(true){
    if (!client81.connect(host, stream_port)) {
      Serial.print(',');
    }else{
      Serial.printf("client81.connected(2) %s\r\n", host);
      break;
    }
    delay(500);
  }

  String get_request = "GET /stream HTTP/1.1\r\n";
  get_request += "Host: " + String(host);
  get_request += ":81\r\n";
  get_request += "Connection: keep-alive\r\n\r\n";

  client81.print(get_request);
  get_request = "";
  if(!receiveToBlankLine(client81)){
    Serial.println("connectStreamClient FALSE");
    return false;
  }
  Serial.println("stream header receive OK!");
  wst.isPort81Handshake = true;
  StatusStream = ON_STREAM;
  return receiveStream(client81);
}
//*********************************************
bool receiveStream(WiFiClient &client81){
  uint32_t time_out;
  uint32_t tmp_jpg_len = 0;

  while(StatusStream == ON_STREAM){
jpg_rcv0:
    if(!receiveBoundary(client81)){
      delay(1); continue;
    }
    if(client81.available()){
      time_out = millis();
      char tmp_cstr[5] = {};
      while(StatusStream == ON_STREAM){
        if(client81.available()){
          if((char)client81.read() == '\n') {
            client81.read((uint8_t *)tmp_cstr, 4);
            if(!strcmp(tmp_cstr, "\r\n\r\n")){
              String res_str = client81.readStringUntil('\n');

              while(wst.hasReceiveJpg){ //安定して受信するために必要なループ
                delay(1);
              }

              tmp_jpg_len = strtol(res_str.c_str(), NULL, 16);
              if(tmp_jpg_len) {
                //Serial.printf("tmp_jpg_len=%d\r\n", tmp_jpg_len);
                if(!receiveJpgData(client81, tmp_jpg_len)) {
                  Serial.println("receiveJpgData false");
                }
                time_out = millis();
              }
              delay(1);
              goto jpg_rcv0;
            }
          }
        }

        if(millis() - time_out > 1000UL){
          receiveBoundary(client81);
          time_out = millis();
          delay(1);
        }
      }
    }
    delay(1);
  }
  return false;
}
//********************************************
bool receiveJpgData(WiFiClient &client81, uint32_t tmp_jpg_len){
  if(!wst.hasReceiveJpg){
    if(wst.isPort81Handshake){
      while(StatusStream == ON_STREAM){
        if(client81.available()) {   
          //Serial.printf("before receive jpg heap=%d\r\n", esp_get_free_heap_size());
          jpgt.jpg_len = tmp_jpg_len;
          if(jpgt.buf) {
            free(jpgt.buf);
            jpgt.buf = NULL;
          }
          jpgt.buf = (uint8_t *)malloc(sizeof(uint8_t) * jpgt.jpg_len);

          uint32_t ptr_addrs = 0;          
          int32_t remain_bytes = jpgt.jpg_len;
          int tmp = 0;

          //Serial.printf("tmp_jpg_len=%d\r\n", tmp_jpg_len);
          while(tmp_jpg_len > 0){
            if(StatusStream != ON_STREAM) return false;
            if(client81.available()) {
              tmp = client81.read(&(jpgt.buf[ptr_addrs]), remain_bytes);
              if(tmp < 1){
                delay(1); continue;
              }
              ptr_addrs += tmp;
              remain_bytes = remain_bytes - tmp;
              if(remain_bytes <= 0) break;
            }else{
              return false;
            }
            delay(1);
          }
          if(jpgt.jpg_len) {
            wst.hasReceiveJpg = true;
          }
          return true;
        }
        delay(1);
      }
    }
  }
  return false;
}
//********************************************
bool receiveBoundary(WiFiClient &client81){
  char cstr[16] = {};
  uint32_t time_over = millis();
  while(StatusStream == ON_STREAM){
    if(client81.available()){
      if((char)client81.read() == '-'){
        if(client81.read((uint8_t*)cstr, 15)){
          if(strcmp(cstr, "-myboundary\r\n\r\n") == 0){
            return true;
          }
        }
      }
    }
    if(millis() - time_over > 1000UL) {
      time_over = millis();
      delay(1);
    }
    delay(1);
  }
  return false;
}
//********************************************
void connectClient80(WiFiClient &client80){
  int httpPort = 80;
  while(true){
    if(SelectCamCtrl == START_STREAM){
      if (client80.connect(host, httpPort)) {
        Serial.printf("client80.connected! %s\r\n", host);
        break;
      }else{
        Serial.print(',');
      }
    }
    delay(1);
  }
}
//********************************************
void sendPing80(WiFiClient &client80, uint32_t interval_time){
  if((millis() - wst.ping80_lasttime) > interval_time){
    sendCamCtrlRequest(client80, "ping80", "0");
    wst.ping80_lasttime = millis();
  }
}
//********************************************
void changeCamControl(WiFiClient &client80){
  if(wst.canSendCamCtrl){
    String id_str = "", val_str = "0";
    switch(SelectCamCtrl){
      case STOP_STREAM:
        id_str = "stop_stream";
        StatusStream = OFF_STREAM;
        wst.isPort80Handshake = false;
        break;
      case START_STREAM:
        id_str = "start_stream";
        break;
      case RESET_CAM:
        id_str = "reset";
        break;
      default:
        id_str = "ping80";
        break;
    }
    sendCamCtrlRequest(client80, id_str, val_str);
    SelectCamCtrl = PING80;
  }
}
//********************************************
void sendCamCtrlRequest(WiFiClient &client80, String id_str, String val_str){
  String get_request = "GET /control?var=" + id_str;
  get_request += "&val=" + val_str;
  get_request += " HTTP/1.1\r\n";
  get_request += "Host: " + String(host);
  get_request += "\r\nConnection: keep-alive\r\n\r\n";
  client80.print(get_request);
  Serial.print(get_request);
  get_request = "";
  id_str = "";
  val_str = "";
  receiveToBlankLine(client80);
  wst.canSendCamCtrl = false;
}
//*********************************************
bool receiveToBlankLine(WiFiClient &client){
  String req_str = "";
  uint32_t time_out = millis();
  while(StatusStream == ON_STREAM){
    if(client.available()){
      req_str =client.readStringUntil('\n');
      if(req_str.indexOf("\r") == 0) return true;
    }
    if(millis() - time_out > 10000) {
      Serial.println("--------error Time OUT receiveToBlankLine");
      break;
    }
    delay(1);
  }
  return false;
}
//********************************************
void connectToWiFi(){
  Serial.println("Connecting to WiFi network: " + String(ssid));
  WiFi.disconnect(true, true);
  delay(1000);
  WiFi.onEvent(WiFiEvent);
  WiFi.begin(ssid, password);
  Serial.println("Waiting for WIFI connection...");
}

void WiFiEvent(WiFiEvent_t event){
  switch(event) {
    case SYSTEM_EVENT_STA_GOT_IP:
      Serial.println("WiFi connected!");
      Serial.print("My IP address: ");
      Serial.println(WiFi.localIP());
      delay(1000);
      wst.isWiFiConnected = true;
      break;
    case SYSTEM_EVENT_STA_DISCONNECTED:
      Serial.println("WiFi lost connection");
      wst.isWiFiConnected = false;
      break;
    default:
      break;
  }
}
//********************************************
void changeStateStreamBtnDisp(){
  if(StatusStream == OFF_STREAM || StatusStream == CLOSE_CONNECTION){
    SelectCamCtrl = START_STREAM;
    StatusStream = ON_STREAM;
  }else if(StatusStream == ON_STREAM){
    SelectCamCtrl = STOP_STREAM;
    StatusStream = CLOSE_CONNECTION;
  }
  wst.canSendCamCtrl = true;
  Serial.printf("SelCamCtrl=%d\r\n", (uint8_t)SelectCamCtrl);
}
//********************************************
bool decodeJPG(jpg_buf_t &jpgt, bmp_buf_t &bmpt){
  void *work = (void*)malloc(3100);
  lgfxJdec jdec;
  JRESULT res;
  bool ret = false;

  res = lgfx_jd_prepare(&jdec, in_func, work, 3100, &bmpt);
  if (res == JDR_OK) {
    bmpt.w_bmp = jdec.width;

    res = lgfx_jd_decomp(&jdec, out_func, 0);
    if (res == JDR_OK) {
      ret = true;
    } else {
      Serial.printf("Failed to decompress: rc=%d\r\n", res);
      ret = false;
    }
  } else {
    Serial.printf("Failed to prepare: rc=%d\r\n", res);
    ret = false;
  }
  jpgt.buf_seek = 0;
  free(work);
  work = NULL;
  if(jpgt.buf) {
    free(jpgt.buf);
    jpgt.buf = NULL;
  }
  jpgt.jpg_len = 0;
  return ret;
}

uint32_t in_func (lgfxJdec* jd, uint8_t* buff, uint32_t nbyte){
  if (buff) {
    memcpy(buff, jpgt.buf + jpgt.buf_seek, nbyte);
    jpgt.buf_seek += nbyte;
    return nbyte;
  }else{
    jpgt.buf_seek += nbyte;
    return nbyte;
  }
}

uint32_t out_func (lgfxJdec* jd, void* bitmap, JRECT* rect){
  bmp_buf_t *dev = (bmp_buf_t*)jd->device;
  uint8_t *src, *dst;
  uint16_t y, bws, bwd;
  //RGB888
  src = (uint8_t*)bitmap;
  dst = dev->buf + 3 * (rect->top * dev->w_bmp + rect->left);
  bws = 3 * (rect->right - rect->left + 1);
  bwd = 3 * dev->w_bmp;
  for (y = rect->top; y <= rect->bottom; y++) {
      memcpy(dst, src, bws);
      src += bws; dst += bwd;
  }
  return 1;
}

【ザッと簡単解説】

M5CameraとM5Stackの動画ストリーミングWiFi通信については、1-3節で紹介したコードと同じです。
畳み込みニューラルネットワーク演算については、前回記事で紹介したコードとほぼ同じです。
重複しているところは割愛します。

●10行目:
2-1節で紹介したように、SONY Neural Network Console で学習させた重みとバイアスデータのインクルードです。

●21行目:
ここは先ほどでも説明したように、M5CameraのローカルIPアドレスに書き換えてください。

●121行目:
byte型のRGB888フォーマットビットマップデータを、畳み込みニューラルネットワーク(CNN)用にfloat型に変換する必要がありますが、その時に255で毎回除算(割り算)すると計算時間がかかります。
計算の速い乗算(掛け算)にするために、事前に小数を算出して定数化しておきます。

●122行目:
畳み込みニューラルネットワーク演算を200ms間隔で処理するための定数です。

●127-204行:
畳み込みニューラルネットワーク(CNN)のパラメータ初期化です。

●266行:
dispImgPlusRect関数で、160×120 pixel画像に緑色のボックス表示を上書きしています。

●28-271行:
ここで、200ms毎に畳み込みニューラルネットワーク演算実行させています。

●312-338行:
ここで、LCD表示用のRGB888ビットマップデータに112×112 pixel部分の緑色ボックス線を上書きしています。
ターゲットに合わせやすくするためです。

●340-419行:
ここで160×120 pixelの画像を28×28 pixelまで圧縮して前処理して、畳み込みニューラルネットワーク(CNN)処理させています。
350-352行で、Bボタン(中央のボタン)が押されたら、前処理後の28×28 pixel画像を3倍に引き伸ばして、LCD表示させています。

●421-511行:
畳み込みニューラルネットワーク処理の前に28×28 pixelに圧縮させる前処理の関数群です。

●513-707行:
前回記事とほぼ同じ、畳み込みニューラルネットワーク(CNN)の関数群です。

●709-1114行:
1-3節で紹介した、M5CameraとM5Stack間で動画ストリーミングするための関数群です。

2-3. コンパイル書き込み実行

では、M5Cameraを起動し、1-4節と同様の設定でコンパイル書き込み実行させています。

操作方法や表示の意味は以下の通りです。

(図02-03-01)

Aボタン(左側ボタン)は動画ストリーミング開始および停止ボタンです。

Bボタン(中央ボタン)は画像の前処理で、白黒反転させて28×28 pixelに圧縮し、3倍に引き伸ばした画像を表示させるか否かというボタンです。
これにより、ターゲットに合わせやすくなり、照明の具合による影の影響も確認できます。

あとは、最初の方で紹介した動画のように動作すればOKです。
ただ、太字のマジックでハッキリ書かないと認識率は悪いです。
細字の文字でも認識するようにするためには、画像の前処理をもっと工夫するか、新たなデータで学習し直すしかないかなと思います。

3.今後の課題点

今回の問題点は、グローバル領域に650byte分のCNNの重みとバイアスデータを定義していることと、何よりも160×120 pixel分のカメラ画像をグローバル領域に確保してしまっていることです。
カメラ画像のビットマップデータは、
160×120×3色=  57600byte
という膨大なメモリを消費してしまっています。
畳み込みニューラルネットワーク演算の方に優先的にメモリを使いたいのに、これでは本末転倒です。

以前のこちらの記事では、先ほど述べたようにJpgLoopAnimeライブラリを使いましたが、このライブラリのように、画像を一気にビットマップデータに変換せず、画像を部分的に区切ってビットマップに変換しながらニューラルネットワーク処理をするという方式にした方が良いかも知れません。
もちろん、PSRAMなどがあればそこに一気にJPEG画像を解凍しても良いかも知れませんが、M5Stack BasicにはPSRAMはありませんので、その場合はSPIFFSを使うなどの対処が必要かも知れません。
いずれにしても、非力なマイコンでリアルタイム画像表示とCNNの並行処理を行うのは、どちらかが何らかの犠牲を払うことになりそうです。

また、受信側のM5Stackの方にPSRAMが無いのであれば、送信側のM5CameraのESP32-WROVERのPSRAMにJPEG画像を解凍させて、M5Camera側でCNN処理させて、判定結果とJPEG画像をM5Stackに送るという方法も有りかも知れませんね。

いずれは、MNISTデータではなく、取得した実画像から学習データを作成して、改めて学習し直すプログラミングを考えねばなりません。
その為にはどうやって画像データを学習データに変換して、それをどの様にして再学習させるのか、運用方法をいろいろと考えねばなりませんね。
AI搭載を謳う自作ガジェットを作ろうとして、自力で出来る限りのことはやろうとすると、なかなか色々なハードルがあるもんですね。

まとめ

やっとAI搭載ガジェットっぽいことができるようになってきました。
さすがに、学習しながら判定ということはまだまだ無理ですが、こんな小さなマイコンモジュールでLCD(液晶ディスプレイ)表示させながらニューラルネットワークで画像判定ができるというだけで、AIっぽさが増しますね。

今回はイメージセンサの画像をWiFiで飛ばして、リアルタイムにLCDに表示させながら画像判定を行うことに焦点を当てて作ってみました。
メモリ管理が難しかったのですが、何とかギリギリ動作したのでヨシとします。

手書き数字の学習データは60000件のMNISTデータなので、実際のデータで学習させた方がより高い確率で認識できるようになると思います。
それは来年挑戦してみようと思います。
その他、カラー画像や物体認識もやってみたいですね。

というわけで、以上でたぶん今年最後の投稿となりそうです。
今年の目標だったディープラーニングの勉強開始も何とか達成できました。

今年もサポートしてくださった皆さま、本当にありがとうございました。
おかげでブログ運営が継続できております。
感謝感謝です。
もしかしたら、31日までに今年のサポート報告できるかも知れません(いや、できないかも)。

今年はコロナで散々でしたが、来年こそはもっと楽しい世になるといいですね。
ではみなさま、良いお年を・・・。

Amazon.co.jp 当ブログのおすすめ

スイッチサイエンス ESPr Developer 32 Type-C SSCI-063647
スイッチサイエンス
¥2,420(2024/11/21 16:13時点)
ZEROPLUS ロジックアナライザ LAP-C(16032)
ZEROPLUS
¥19,358(2024/11/20 21:45時点)
Excelでわかるディープラーニング超入門
技術評論社
¥2,068(2024/11/21 12:24時点)

コメント

タイトルとURLをコピーしました