プログラミング

SONYのボード「SPRESENSE」を使ってマイコン上で画像認識させる方法を紹介

Spresense(スプレセンス)とはソニーが開発・販売しているArduino互換のマイコンボードです。圧倒的な低消費電力や高精度なGPS機能が特徴です。

今回は先月参加させていただいた、TechSeeker HACKATHON 2024で使用したSpresenseを使った画像認識についてできる限り解説します。

はじめに

今回は、TechSeeker HACKATHON 2024で作成した「電脳猫の手」で使用した画像認識について解説します。電脳猫の手はSpresenseのメインボード, 拡張ボード, HDRカメラボードで画像認識を行い、3つのサーボモーターを動かすことで人とのコミュニケーションを代わりに行ってくれる第3の腕です。

パーを検出するとお手をします
グーを検出するとネコパンチをします

Spresenseで画像認識させるには

Spresenseは高い処理性能を持ちますが、OpenCVのような重い処理をするのには向いていません。というのも、OpenCVは元々PC向けのライブラリのため、PC上で認識処理を行うか、Raspberry Piのような、高消費電力・超高速処理が可能なマイコンを使う必要があります。

そのため、NNC(Neural Network Console)というサービスを用いてマイコン上でも処理を行えるエッジAIを作成し、そちらで画像認識処理をする必要があります。

エッジAIとは、画像認識などのAI(人工知能)の処理を、クラウド上ではなくマイコンなどのデバイス側で行う技術です。データの送受信の必要がなく、完全にオフラインで簡単なAIが動くのが大きなメリットです。
身近な例で言うと、スマホの顔認証や音声入力などが挙げられます。

顔検出に挑戦するも...

ハッカソンの1ヶ月の期間のうち、1週間半近くをNNCに使いましたが、学習に使用するデーターの準備やファイルのアップロードなどでつまずいてしまい、自作のエッジAI作りはできませんでした。

顔検出用の画像データのアノテーション作業をする環境の準備、学習データの作成もするも結局使えず...下調べは大事!

他のチームの方は1日でモデルを作成し、画像認識できるところまでできたそうなので、公式のリファレンスやソニーさんの出してる技術書を読めばどなたでもできるはずなのでぜひチャレンジしてみてください💦

ハッカソンでは顔検出を諦め、NNCにあるサンプルのグーチョキパーを検出するエッジAIを使用しました。

画像認識の結果でモーション切り替え

ここからは実際のコードを交えて解説します。今回使用したライブラリはソニーさんがSpresense向けに公開している Spresense Arduino Library のSDカード、カメラ、ニューラルネットワークを制御するヘッダーファイルを使用します。
ライブラリのインストール方法などはSpresense公式ページのスタートガイドに詳細な解説があるためそちらを参考にしました。

ライブラリ、ヘッダーファイルの読み込み

  1. #include <stdio.h> // 標準ライブラリ
  2. #include <Servo.h> // サーボモーター
  3. #include <DNNRT.h> // Spresense, ニューラルネットワーク
  4. #include <SDHCI.h> // Spresense, SDカード
  5. #include <Camera.h> // Spresense, カメラ
  6. #include "ge.h" // 画像の下処理に使用
  7. #include <SPI.h> // SPI通信
  8. #include "Adafruit_ILI9341.h" // TFT液晶, ディスプレイ

ライブラリとヘッダーファイルの読み込みを行います。下2つはディスプレイで表示するためのライブラリなので、ディスプレイを使用しない場合は不要です。

変数, インスタンスの宣言

  1. // For the Adafruit shield, these are the default.
  2. #define TFT_RST 8
  3. #define TFT_DC 9
  4. #define TFT_CS -1
  5. // Use hardware SPI (on Uno, #13, #12, #11) and the above for CS/DC
  6. Adafruit_ILI9341 tft = Adafruit_ILI9341(TFT_CS, TFT_DC, TFT_RST);
  7. #define WHITE_BALANCE_FRAMES 24
  8. int nframes;
  9. DNNRT dnnrt;
  10. SDClass SD;
  11. uint8_t buffer[28 * 28] __attribute__((aligned(16)));
  12. DNNVariable input(28 * 28);
  13. volatile int now_label = 2;
  14. float threshold[4];

1〜7行目ではディスプレイのオブジェクトのPINの定義、オブジェクトの宣言を行なっています。

9行目10行目ではカメラのホワイトバランスを調節する時間の宣言とそれ用の変数の宣言を行なっています。Spresenseのカメラのホワイトバランスの自動調節は初期設定でONになっていますが、24フレーム目でそれをオフにするようにします。そうすることで処理の負荷を軽減しつつ、最初の24フレームでホワイトバランスを安定させています。

9行目ではDNNRTクラス, 10行目ではSDClassクラスのオブジェクトを宣言しています。

15〜19行目ではSpresenseの画像認識に必要な宣言を行なっています。
・15行目:8ビット整数型の長さ784の配列の宣言
 →attribute((aligned(16))): によって先頭アドレスを16バイト境界に揃えます。
・16行目:DNNRT ライブラリで定義されている変数型を長さ784で宣言
・18行目:現在認識されたジェスチャーを格納する変数。割り込みでの処理によって値が変更されるため、"volatile"をつけて宣言しています。
・19行目:ジェスチャー認識の閾値を格納します。

♦︎配列の長さ784について♦︎
今回使用するモデルの入力は28×28=784ピクセルの白黒画像のため、15, 16行目では配列の長さをそのように宣言しています。

setup()関数での処理

  1. void setup() {
  2.   // Initialize variables
  3.   nframes = 0;
  4.   threshold[0] = 0.6; // グーの閾値
  5.   threshold[1] = 0.8; // チョキの閾値
  6.   threshold[2] = 0.8; // パーの閾値
  7.   threshold[3] = 0.4; // 「なにもない」の閾値
  8.   /* Open serial communications and wait for port to open */
  9.   Serial.begin(115200);
  10.   while (!Serial)
  11.   {
  12.     ; /* wait for serial port to connect. Needed for native USB port only */
  13.   }
  14.   // サーボのアタッチ
  15.   myservoD.attach(5); // ボードで5のところは5番ピン
  16.   myservoM.attach(6); // ボードで6のところは6番ピン
  17.   myservoU.attach(3); // ボードで7のところは3番ピン
  18.   /* Start USB Mass Storage */
  19.   Serial.println("USB Mass Storage start");
  20.   SD.begin();
  21.   SD.beginUsbMsc();
  22.   /* Initialize DNNRT */
  23.   Serial.println("Loading network model");
  24.   File nnbfile("result.nnb"); //NNCハンド認識 読み込み
  25.   if (!nnbfile) {
  26.     Serial.println("nnb not found");
  27.     return;
  28.   }
  29.   Serial.println("Initialize DNNRT");
  30.   int ret = dnnrt.begin(nnbfile);
  31.   if (ret < 0)
  32.     {
  33.       Serial.println("DNNRT initialize error.");
  34.     }
  35.   tft.begin(40000000);
  36.   tft.setRotation(3);
  37.   /* begin() without parameters means that
  38.    * number of buffers = 1, 30FPS, QVGA, YUV 4:2:2 format */
  39.   Serial.println("Prepare camera");
  40.   theCamera.begin(2);
  41.   /* Start video stream.
  42.    * If received video stream data from camera device,
  43.    * camera library call CamCB.
  44.    */
  45.   Serial.println("Start streaming");
  46.   theCamera.startStreaming(true, CamCB);
  47.   /* Auto white balance configuration */
  48.   Serial.println("Set Auto white balance parameter");
  49.   theCamera.setAutoWhiteBalanceMode(CAM_WHITE_BALANCE_DAYLIGHT);
  50. }

setup()関数では色々書いてありますが、ここでは画像認識に絞って補足説明をします。

33行目ではNNCで事前に学習済みのニューラルネットワークモデルを読み込んでいます。40行目では、読み込んだnncファイルでDNNRTクラスのインスタンスdnnrtの初期化を行なっています。dnnrtはこの後、CamCB()関数 内で推論を行う際に呼び出します。

53行目ではカメラの初期化、61行目ではカメラのビデオストリームを開始します。これによって常にカメラで取得した画像がSpresenseに流れ続けます。また、ビデオストリームによってデータが受信されるたびに割り込み処理が発生し、CamCB()関数 が呼び出されます。
また、startStreaming(true, CamCB) で開始したビデオストリームは startStreaming(true, CamCB) で停止することができます。

CamCB()関数:コールバック関数について

コールバック関数とは、他の関数から呼び出される関数のことです。今回使用する CamCB()関数 はカメラ("Cam"era)から画像が取得されたとき呼び出されるコールバック関数("C"all "B"ack)になります。

CamCB()関数はSpresenseの公式チュートリアルページのCameraチュートリアルでも解説がありますのでそちらもご確認ください。

  1. void CamCB(CamImage img) {
  2.   float *input_buffer;
  3.   int label;
  4.   unsigned int i;
  5.   /* Check the img instance is available or not. */
  6.   if (img.isAvailable()) {
  7.     // Make a gray scale shrink image for dnnrt input data
  8.     GE.shrink(img, buffer);
  9.     // Get input data buffer
  10.     input_buffer = input.data();
  11.     // Convert gray scale values to float
  12.     for (i = 0; i < 28 * 28; i++) {
  13.       input_buffer[i] = (float)buffer[i];
  14.     }
  15.     // Set converted input data to DNNRT
  16.     dnnrt.inputVariable(input, 0);
  17.     // Do recognition
  18.     dnnrt.forward();
  19.     // Get recognition result
  20.     DNNVariable output = dnnrt.outputVariable(0);
  21.     // 0: rock
  22.     // 1: scissor
  23.     // 2: paper
  24.     // 3: unknown
  25.     label = 3;
  26.     for (i = 0; i < output.size(); i++) {
  27.       if (output[i] > threshold[i]) {
  28.         label = i;
  29.       }
  30.     }
  31.     now_label = label;
  32.     // Show hand icon on the
  33.     GE.decolate(img, label);
  34.     // Convert color format to be able to show on the LCD
  35.     img.convertPixFormat(CAM_IMAGE_PIX_FMT_RGB565);
  36.     // Draw manipulated frame buffer on the LCD
  37.     tft.drawRGBBitmap(0, 0, (uint16_t *)img.getImgBuff(), 320, 240);
  38.     // Check frame counts for stop auto white balance
  39.     if (nframes < WHITE_BALANCE_FRAMES) {
  40.       nframes++;
  41.     }
  42.     else if (nframes == WHITE_BALANCE_FRAMES) {
  43.       // Disable auto white balance
  44.       theCamera.setAutoWhiteBalance(false);
  45.     }
  46.   }
  47. }

startStreaming()関数 によって呼び出されるこの関数は、CamImage というフレームに関する情報を格納したインスタンスを引数に受け取ります。画像は色付きでサイズも違うためニューラルネットワークで処理できるよう下処理をする必要があります。
・9行目:グレースケール化
・11〜17行目:28 × 28ピクセルに変換
使用するモデルの入力層の形に変形する必要があるため、少し工夫が必要そうです。

変換されたデータ input をもとに19〜26行目で推論を行います。なんとたったこれだけです。出力結果は各クラス(グー, チョキ, パー, アンノウン)の確率値の配列が返されます。例えば90%グーという結果の場合は[0.9, 0.05, 0.03, 0.02]のような配列になります。こちらも使用するモデルによって出力層の形が異なります。

33〜39行目では推論の結果をもとに大域変数のnow_labelを書き換えます。また、それぞれの閾値を配列などで指定することで検出しやすさを変えることができます。今回使用したサンプルはグーが検出しづらく、パーが検出しやすかったため、グーの閾値を上げ、パーの閾値を下げました。ここは色々試しながら調節するのがいいと思います。

50〜57行目ではホワイトバランスの自動調節をフレーム数がWHITE_BALANCE_FRAMESを超えると切る処理を行なっています。

loop()関数での処理

setup()関数にて開始した、theCamera.startStreaming(true, CamCB);の処理がloop()関数とは別に実行され、バックグランドで推論が行われるため、labelの変化を待つようなループを作成しています。

  1. void loop() {
  2.   int label = now_label;
  3.   while(label == now_label){ // 変化したら検出
  4.     
  5.   }
  6.   theCamera.startStreaming(false, CamCB); // 動作中はビデオストリームを中断
  7.   playMotion(now_label);
  8.   delay(1000);
  9.   theCamera.startStreaming(true, CamCB); // 動作後はビデオストリームを再開
  10. }

playMotion()関数 では引数の now_label に応じて対応するモーションを行います。

label は「0:グー, 1:チョキ, 2:パー, 3:アンノウン」に対応しているため、playMotion()関数 は引数が1と3の時は何もしない、0の時は「ネコパンチ」、2の時は「お手」をするモーション通りにサーボモーターを動かすように設定しています。モーション作りの方法やコードの書き方はこちらの記事でも紹介しているので参考にしてください。

また、モーション中はビデオストリームを startStreaming(false, CamCB); によって停止させることで、割り込み処理をなくし、滑らかに動作するようにしています。
startStreaming(false, CamCB); はビデオストリームを停止させるだけでカメラモジュールを完全には止めないため、起動に時間がかかったり、起動後のホワイトバランスの設定などを考慮する必要がないため、気軽につけたり消したりできるみたいです。
当然モーション後は startStreaming(true, CamCB); でビデオストリームを再開させます。

まとめ

今回はSpresenseを使って画像認識をさせる方法を紹介しました。

  • NNCでエッジAIを入手する
  • カメラのライブストリーミングでカメラからデータを流す
  • カメラで取得したデータの大きさ等を調節し、エッジAIで作成したDNNRTのクラスに入れ、推論の結果を得る

以上の流れで画像認識ができます!ここまで読んでくださりありがとうございます。

謝辞

このコードはTechSeeker Hackathon 2024の成果物の一部です。このような学習の機会を用意してくださった方々、同じチームとして支えてくださったデジもく会のみなさんに感謝します。本当にありがとうございました。

-プログラミング
-, , ,