Spresense でフルカラー画像を認識する


# はじめに
 ROHM Open Hack Challenge 2019 (ROHC2019) で開発している当時、Spresense のグレースケール画像の認識をするためのサンプルコードは見つかったのですが、フルカラー画像に相当するものが見つからなかったので、試行錯誤しながら作ってみました。
本稿ではその時にやったことを記載しています。

# 訓練データを集めるための筐体
 後述しますが、Spresense は1.5MByte しかメインメモリがないため、機械学習の学習済みパラメータをできる限り小さくする必要があります。つまり、認識器の表現力はどうしても低くなってしまうため、ROHC2019 ではなるべく外乱を抑えるようカメラの位置と被写体の背景が固定されるような筐体を作りました。

撮影装置の外観はこんな感じです。
![a](https://protopedia.net/sites/default/files/prototype/1868/img/%E7%94%BB%E5%83%8F%E8%AA%8D%E8%AD%98%E8%A3%85%E7%BD%AE%E5%A4%96%E8%A6%B3.JPG)

## モジュールの構成

* Spresense メインボード
* Spresense用カメラボード（言わずもがな、画像データの収集と推論に必要）
* Spresense 拡張ボード（訓練データを集めるためＳＤカードの読み書きが必要）
* Bluetooth®LE Add-onボード「SPRESENSE-BLE-EVK-701」(認識結果を外部へ送信するのに必要)

## 不要な画像データを早めに排除する

筐体にはスイッチやボタンをつけていて、以下のような切り替えを行います。

 * スイッチ１
    * ＳＤカードにキャプチャした画像を蓄積していく学習モードと、キャプチャ画像からＩＤを推論する推論モードとを切り替えます。
 * スイッチ２
    * 学習モードの場合はＳＤカードに画像を記録するかどうか、推論モードの場合はデータの送信を行うかどうかを切り替えます
 * ボタン
    * ボタンを押している間は、スイッチ２の状態にかかわらず画像の送信を行いません
   
常に画像の保存をし続けるようにすると、撮影対象の画像の姿勢を変える時に自分の手が移りこんでしまい、訓練データとしては使いにくくなってしまうため、上記のような記録を抑制するスイッチ・ボタンを設けて、不要な画像の記録を抑止し、クレンジングの手間を減らすようにしています。

## Spresense 拡張ボードを取り付ける時の注意事項

以下のサイトにとても大事なことが書いてあります。
https://developer.sony.com/develop/spresense/developer-tools/get-started-using-nuttx/hardware-overview#_how_to_connect_and_prepare_the_spresense_main_board_and_spresense_camera_board

こんなことが書いてあります。


> Do not separate the Spresense main board from the extension board by force once mounted.

はい、**一度メインボードを拡張ボードにくっつけたら、絶対に外すな**ということです。
筆者はこれを読まずにメインボードを拡張ボードから外すと、メインボードに書き込めなくなってしまいました（spresense 買いなおしました（泣））

# Spresense で画像をキャプチャし、ＳＤカードに保存する
## Arduino の環境を構築する

まず、本家のサイトを参考にしながら環境構築を実施します。
https://developer.sony.com/ja/develop/spresense/developer-tools/get-started-using-arduino-ide/set-up-the-arduino-ide

使用したArduino IDE のバージョンは1.8.10 です。
まず、「ファイル」→「環境設定」を選択し、追加のボードマネージャのＵＲＬに以下のアドレスを入力します。
`https://github.com/sonydevworld/spresense-arduino-compatible/releases/download/generic/package_spresense_index.json`

![arduino_setting.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/143965/ae43bc0c-8a18-a084-1482-2c3ba9befea9.png)

続いて、「ツール」→「ボード○○○」→「ボードマネージャ」を選択し、「spresense」で検索してパッケージをインストールします。
![arduino_board_manager.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/143965/1a2e251d-348d-19b8-01d1-c24e96e8d13c.png)

その後、「ツール」からSpresense を選択すると、開発の準備完了です。

## Arduino で画像をキャプチャ・保存する

まず必要なヘッダをinclude します。

```
#include "MK71251.h" // BLE用(詳細は後述)
#include <string.h>
#include <SDHCI.h> // SDカード用
#include <stdio.h>  /* for sprintf */

#include <Camera.h> // カメラ用
#include <DNNRT.h> // 推論用
```

続いてカメラモジュールを初期化します。

```
  theCamera.begin(); // 初期化


  theCamera.startStreaming(true, CamCB); // CamCB については後述

  // 学習・推論共に同じ場所で、常に撮影環境を同じ場所にできるならば、
  // ホワイトバランスのモードをその場所に最も合うように選択するのが良い。
  theCamera.setAutoWhiteBalanceMode(CAM_WHITE_BALANCE_INCANDESCENT ); 

  theCamera.setStillPictureImageFormat(
     CAM_IMGSIZE_QVGA_H,
     CAM_IMGSIZE_QVGA_V,
     CAM_IMAGE_PIX_FMT_JPG);
```

カメラ画像のキャプチャはコールバック関数で行います。

```
const int cam_width = 320; // キャプチャした画像の幅・高さ
const int cam_height = 240; 
const int dscale_x = 4; // キャプチャ画像を内部バッファに収める時の縮小率(1/4)
const int dscale_y = 4; 
const int img_size = (cam_width / dscale_x)* (cam_height / dscale_y) * 3; // 内部バッファのサイズ
const int  img_width = ((cam_width) / (dscale_x)); // 内部バッファの幅・高さ
const int  img_height = ((cam_height) / (dscale_y));

uint8_t img_buf[img_size]; // 内部バッファ

void CamCB(CamImage img)
{

  /* Check the img instance is available or not. */

  if (img.isAvailable())
    {
      img.convertPixFormat(CAM_IMAGE_PIX_FMT_RGB565);

      if(busy == 0) { // バッファを処理中の時は書き込まない（ロック機構としては弱いがとりあえずで…）
        uint8_t* buf = img_buf; 
        uint16_t* bf = (uint16_t*)img.getImgBuff(); ; 
      
        // QVGA のままだと学習済みパラメータが大きくなってしまい、
        // メモリに乗らなくなってしまうため、
        // キャプチャした時点で画像サイズを小さくする。
        for(int y=0;y<cam_height;y += dscale_y) {
          for(int x=0;x<cam_width;x += dscale_x) {
            uint8_t r = ((*bf) >> 11 & 0x1f) << 3; 
            uint8_t g = ((*bf) >> 5 & 0x3f) << 2; 
            uint8_t b = ((*bf) >> 0 & 0x1f) << 3; 
            *buf = g; 
            buf++; 
            *buf = b; 
            buf++; 
            *buf = r; 
            buf++; 
            bf += dscale_x; 
          }
          bf += cam_width * (dscale_y - 1); 
        }
      }
      Serial.print(".");
    }
  else
    {
      Serial.print("Failed to get video stream image\n");
    }
}
```

そして、キャプチャした画像をＳＤカードに書き込みます。書き込むときのフォーマットはPPM（Portable pixmap）です。
Jpeg 形式よりサイズは大きくなりますが、情報を落とさないようにするため、少々サイズは大きいですがPPM 形式で保存します。

```
void writePPM(char* filename)
{
// 追記させないように、あらかじめファイルを消しておく。
  if(theSD.exists(filename)) {
   theSD.remove(filename); 
  }
  
  File f = theSD.open(filename, FILE_WRITE); 
  f.println("P6"); 
  f.println(String(img_width) + String(" ") + String(img_height)); 
  f.println("255"); 

  busy = 1; // バッファをロックする
  f.write(img_buf, img_size); 
  busy = 0; 
  
  f.close(); 
}

```

これで、機械学習をするためのデータが集まりました。

## ROHC2019 のデモ（予選で落ちたよ・・・）

食べ物とカロリーの関係についての教育目的で、ROHC2019 では以下の14種類の物体を認識させるようクラスタリングを行いました。

 * バナナ
 * 柿
 * 赤いブドウ
 * 緑のブドウ
 * りんご
 * クラッピー
 * じゃがいも
 * にんじん
 * きゅうり
 * たまねぎ
 * トマト
 * キウイフルーツ
 * たまご
 * （何もない状態）


# NeuralNetworkConsole(NNC) で学習及び評価を行う

## 下準備：画像リストを生成する
NNC で学習を行うためには、訓練データと評価データのリストを作っておく必要があります。
リストの中身はこんな感じで、画像ファイルへのパスとクラスタ番号を記述します。

```
x:image,y:label
data/0/p1/002_00~2.png,0
data/0/p1/002_00~2.png,0
data/0/p1/002_00~2.png,0
data/0/p1/002_00~2.png,0
data/0/p1/002_00~2.png,0
data/0/p1/002_00~2.png,0
data/0/p1/002_00~2.png,0
data/0/p1/002_00~2.png,0
data/0/p1/002_00~2.png,0
```

一方、先の手順で画像ファイルはPPM形式で保存されており、NNC で読み込むためには画像ファイルフォーマットを変換する必要があります。そこで、画像をクラスタ毎にフォルダわけしておき、python のPILLOW を使って画像変換とリスト生成とを同時にやってしまいます。

```
# -*- coding: utf-8 -*-
import os
import sys
import glob
import numpy as np
from PIL import Image
from numpy import *


rate = 0.2 # 訓練データ８割、評価データ２割
duplicate = 100 # 水増し用に、同じファイルをリストに複数個列挙しておく

train = []
eval = []

for i in range(0, 65536): # クラスタi のデータはdata/i/ 以下に置いておく。
	dir = 'data/' + str(i)
	if not os.path.exists(dir):
		continue
	l = glob.glob(dir + '/**/*.ppm', recursive=True)
	id_all = np.random.choice(len(l), len(l), replace=False) # ファイルをランダムに並び替える
	train_max = int((1.0 - rate) * len(l)) # ランダム順のリストから一部を抜き出す
	train.extend([(l[id_all[j]].replace('\\', '/'), i) for j in range(0, train_max)])
	eval.extend([(l[id_all[j]].replace('\\', '/'), i) for j in range(train_max, len(l))])

with open('train.csv', 'w') as f: # 訓練データのリスト
	f.write('x:image,y:label\n')
	for t in train:
		ppm=t[0]
		png=ppm[:-4] + '.png' # 変換後のファイルのパス
		Image.open(ppm).save(png)

		for i in range(0, duplicate):
			f.write(png + ',' + str(t[1]) + '\n')

with open('eval.csv', 'w') as f:
	f.write('x:image,y:label\n')
	for t in eval:
		ppm=t[0]
		png=ppm[:-4] + '.png' # 変換後のファイルのパス
		Image.open(ppm).save(png)
		for i in range(0, duplicate):
			f.write(png + ',' + str(t[1]) + '\n')

```

後述しますが、訓練データの量を水増しにより増やすため、訓練データ・評価データのリストには同一の画像ファイルパスを複数（水増し分）列挙しておくのがポイントです。

## ネットワークを作成する（LeNet）
### サイズを優先してLeNet を使用

学習済みパラメータのサイズを抑えるため、ネットワークにはLeNet を使用しました。
LeNet 内での各重みパラメータの数もなるべく抑えるようにしています。
また、訓練データ・評価データの水増しのため、入力の直後にImageAugmentation を追加します。

spresense のImageAugmentation については以下を参照するとよいかと思います。
http://cedro3.com/ai/image-augmentaion-inflated/

実際のネットワークはこのようにしました。

![training.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/143965/7e4a2898-e5b3-94f8-f28e-dc50cc015dea.png)

### ImageAugmentation のパラメータ

Input の直後に追加しているImageAugmentation のパラメータはこのようにしました。
(ここはまだいろいろ調整の余地がありそうです。)

![ImageAugmentation.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/143965/53da61a9-5828-f0c6-5d4f-0001fc7b2d95.png)

### 水増しの注意点

ImageAugmentation を追加したままspresense 上(Runtime)で推論を行おうとすると、推論がうまく行われません。
常に同じクラスタ番号の発生確率が高くなるようで、Runtime ではImageAugmentation が入っていると入力データがつぶれてしまっている可能性があるようです。
そこで、訓練・評価の時だけImageAugmentation, CategoricalCrossEntropy を使用し、Runtime の時はこれらを外すようにします。
この辺についても以下のリンクが参考になるかと思います。
http://cedro3.com/ai/image-augmentaion-inflated/

ImageAugmentation をRuntime の時に外すとこうなります。
![runtime.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/143965/287062a6-fae4-decc-64e9-def41e9ad1a4.png)

## 学習する

Training を実行して学習済みパラメータを生成します。
結果は以下のようになりました。

![err_rate.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/143965/646ca30c-a8c4-3f1f-79a5-a1a021bbd452.png)

バッチサイズは200, MaxEpoch は80 で学習しています。
筆者の場合、グラボとしてGTX1070 を使用していたので、画面右上にあるSetup アイコンを選択し、Engine からGPU を選択して、GPU を使った学習を行いました。

## 評価する

Evaluation を実行して評価した結果は以下のようになりました。

|指標|結果|
|---|---|
|Accuracy|0.9875|
|Avg.Precision|0.9801|
|AvgRecall|0.9821|
|Avg.F-Measures|0.9796|

クラスタの数を二けたに下あたりからAccuracy が低下してきました。
表現力が小さいので、いきなりクラスタを増やさず、まずは小規模な実験からちょっとずつ拡張することをお勧めします。

評価まで確認したら、TRAINING かEVALUATION のAction からExport -> NNB を選択し、.nnb ファイルを生成します。

# 再びArduino で推論を行う

## .nnb ファイルを読み込む

この辺はサンプルをほとんど参照して構築しています。

```
void setup_nnb()
{
  File nnbfile = theSD.open("model.nnb");
  if (!nnbfile) {
    Serial.print("nnb not found");
    return;
  }
  int ret = dnnrt.begin(nnbfile);
  if (ret < 0) {
    Serial.print("Runtime initialization failure. ");
    if (ret == -16) {
      Serial.println("Please update bootloader!");
    } else {
      Serial.println(ret);
    }
    nnbfile.close();
    return;
  }  
  nnbfile.close(); 
}
```


## キャプチャした画像を推論できる形に変形する

カラー画像を扱う時はここもポイントになるのですが、キャプチャした時のバッファとDNNVariable に突っ込むときのバッファとでデータの並び順が異なるようです（ここは参考になりそうな資料が見つからなかったので、試行錯誤でやりました）。

### キャプチャしたバッファ

||||
|---|---|---|
|B00 R00 G00|B01 R01 G01|...|
|B10 R10 G10|B11 R11 G11|...|
|...|...|...|

### 画像ファイルとして保存するバッファ（訓練に使用）


||||
|---|---|---|
|R00 G00 B00|R01 G01 B01|...|
|R10 G10 B10|R11 G11 B11|...|
|...|...|...|

### DNNVariable に投入するバッファ（推論に使用）

||||
|---|---|---|
|B00|B01|...|
|B10|B11|...|
|...|...|...|
|R00|R01|...|
|R10|R11|...|
|...|...|...|
|G00|G01|...|
|G10|G11|...|
|...|...|...|

キャプチャした色成分の並び順がBGR なのが気持ち悪いです。SDカードに画像を出力してppm ファイルとpng ファイルをそれぞれgimp で開いてみて、色成分の並びを変えないとダメそうだと気付きました。
また、推論に用いるDNNVariable のバッファの並び順はカラープレーン毎に並んでおり、さらにプレーンの色成分の順番もBGR なので同じく気持ち悪い・・・（色の順番が違うというより、バッファ全体で1byte 分ずれてるようにも見えます…）。これに気づかず、実際にspresense でキャプチャから推論までを走らせると全く思うように認識されなかったので、カラープレーン毎に並べてみたり、RGB の順を並び替えてみたりとで、今回の形で意図した認識結果がえられました（気持ち悪さは残りますが…）。

以下、変換のコードです。

```
  DNNVariable input(img_size);
  float *fbuf = input.data();
  char buf[255]; 

  busy = 1; // バッファをロック
  for (int y = 0; y < img_height; y++) {
    for (int x = 0; x < img_width; x++) {
      // img_buf: ＳＤに保存される時のデータ順
      // fbuf: DNNVariable として格納される時のデータ順
      int p, p2; 
      p = (y * img_width + x) * 3 + 2; 
      p2 = (y * img_width + x) + (img_width * img_height) * 0; 
      fbuf[p2] = float(img_buf[p]) / 255.0;
  
      p = (y * img_width + x) * 3 + 0; 
      p2 = (y * img_width + x) + (img_width * img_height) * 1; 
      fbuf[p2] = float(img_buf[p]) / 255.0;
  
      p = (y * img_width + x) * 3 + 1; 
      p2 = (y * img_width + x) + (img_width * img_height) * 2; 
      fbuf[p2] = float(img_buf[p]) / 255.0;
    }
  }
```


## 推論する

```
  dnnrt.inputVariable(input, 0);
  dnnrt.forward();
  DNNVariable output = dnnrt.outputVariable(0);

  Serial.println(output.maxIndex());
  for(int i=0;i<output.size();i++) {
    sprintf(buf, "$%d:%.5f\r\n", i, output[i]); 
    Serial.print(buf); 
  }
```

ここは単純ですね。

データのＢＬＥ送信についてはまた別枠で書いてみようと思います。

# 補足資料
 * [デモ動画（本記事の内容は後半）](https://youtu.be/hQFkhwbqJ7U)
 * [Protopedia記事](https://protopedia.net/prototype/c164bbc9d6c72a52c599bbb43d8db8e1)
 * [プレゼン資料](https://www.slideshare.net/ShinsukeOgata/emedicineboxrohc2019)

## 参照

 * [SONY SPRESENSEボードを使ってみる](https://qiita.com/waku2011/items/b185fa326a04ff709d1d)
 * [Spresense Hardware](https://developer.sony.com/develop/spresense/developer-tools/get-started-using-nuttx/hardware-overview#_how_to_connect_and_prepare_the_spresense_main_board_and_spresense_camera_board)
 * [PNM (画像フォーマット)](https://ja.wikipedia.org/wiki/PNM_(%E7%94%BB%E5%83%8F%E3%83%95%E3%82%A9%E3%83%BC%E3%83%9E%E3%83%83%E3%83%88))
 * [ImageAugmentation](http://cedro3.com/ai/image-augmentaion-inflated/)