製作きっかけ
一番手のかかる年齢の子供が3人おり、それぞれ荷物が異なり、持ち物の準備が大変です。楽しみながら、持ち物の確認ができるといいなと思いながら、製作しました。
製作目的
画像認識による確認で、朝の持ち物の準備を楽しみながら行えるようにするため。
機能
番号 | 機能名 | 実現箇所 |
---|---|---|
1 | 撮像 | カメラボード |
2 | 画像認識 | NNabla C Runtime |
3 | 表示 | LCDディスプレイ |
4 | 時計 | RTC |
5 | 手動入力 | ボタン |
6 | 送信 | ELTRES |
7 | 記録 | CLIP Viewer Lite |
部品表
番号 | 部品名 | 品番 | メーカー | 員数 | 実勢価格 *1 | 短縮URL *1 |
---|---|---|---|---|---|---|
1 | SPRESENSEメインボード | CXD5602PWBMAIN1 | ソニーセミコンダクタソリューションズ | 1 | 6,050円 | ssci.to/3900 |
2 | SPRESENSE拡張ボード | CXD5602PWBEXT1 | 同上 | 1 | 3,850円 | ssci.to/3901 |
3 | SPRESENSEカメラボード | CXD5602PWBCAM1 | 同上 | 1 | 3,850円 | ssci.to/4119 |
4 | SPRESENSE用ELTRESアドオンボード | CEBB-CXM1501GR-02 | クレスコ・デジタルテクノロジーズ | 1 | 12,650円 | ssci.to/7580 |
5 | SPRESENSE用ELTRESアドオンボード対応 LPWAアンテナ | NISSEIEL-ANT2309-231B | 日星電気 | 1 | 770円 | ssci.to/7908 |
6 | uFL接続 15mm GPS用アンテナ | ADA-2461 | Adafruit | 1 | 869円 | ssci.to/2641 |
7 | Mic&LCD KIT for SPRESENSE | AUTOLAB-001 | AUTOLAB | 1 | 7,662円 | ssci.to/7155 |
8 | マイクロSDカード | COTS*2 | N/A | 1 | N/A | N/A |
9 | USBモバイルバッテリ | COTS*2 | N/A | 1 | N/A | N/A |
注記
*1, SWITCH SCIENCE社 2022年8月8日現在
*2, COTS: Commercial Off-The-Shelf 汎用既製品
設計図
はんだ付けなし。下記接続構成の通り、部品表の部品を接続。
ニューラルネットワーク設計図
Sony Neural Network Consoleを用いて実装。取得画像を28x28のグレースケール画像に圧縮。
Image Augmentation、Random Shift後、畳み込み演算を行い、最大値で、Sigmoid関数に入力。
全結合層後は、Softmaxで、分類用交差エントロピーへ。
画像分類は、下記5種類
①コップ ②箸ケース ③マスク ④ファイル ⑤水筒
学習曲線
デフォルトはEpoch数が10回だったので、認識率が低かったものの、徐々に増やして試してみて、5,000回に設定。最終的には98.28%に到達。
学習結果
偏りもなく、十分に認識できている印象。
# | ITEM | RATIO |
---|---|---|
1 | Accuracy | 0.9828 |
2 | Avg. Precision | 0.965 |
3 | Avg.Recall | 0.9863 |
4 | Avg.F-Measures | 0.9744 |
通信結果表示
ELTRESで1分間隔で送信したデータを、株式会社クレスコ・デジタルテクノロジーズのCLIP Viewer Liteを用いて表示。
圧力データに、下記の持ち物があるかどうかをバイナリで下記キャプチャ画像のように表示。(LSBからMSBの順)
①コップ ②箸ケース ③マスク ④ファイル ⑤水筒
持ち物の有無の5ビットとRSSI(受信信号強度)もをグラフ表示。
参照ライブラリ
番号 | ライブラリ名 | 提供者 | バージョン | ライセンス | 使用箇所 |
---|---|---|---|---|---|
1 | Spresense Reference Board | Spresense Community | 2.6.0 | Unknown, LGPL-2.1 licenses found | SPRESENSE一般のハードウェア制御 |
2 | Spresense-LowPower-EdgeAI | 太田 義則 | 記載箇所分からず | LGPL-2.1 license | Neural Network構築・実装全般 |
3 | ELTRESアドオンボード用ライブラリ | CRESCO DIGITAL TECHNOLOGIES, LTD. | 記載箇所分からず | 記載箇所分からず | ELTRESの送信 |
参照図書
番号 | 題名 | 著者 | 出版社 | 価格(税込) |
---|---|---|---|---|
1 | SPRESENSEではじめるローパワーエッジAI | 太田 義則 | オライリー・ジャパン | 3,520円 |
2 | はじめての「SonyNNC」改訂版 | 柴田 良一 | 工学社 | 2,750円 |
製作あとがき
8/10に機材が揃い、製作開始。SPRESENSEは初めてだったので、撮像、画面表示、画像保存、音声取得、FFTによる周波数解析、音声ファイル再生など、サンプルスケッチを動かして使いこなせるようにしました。
8/26から、9/17まで、新型コロナの影響で、全く触れず、諦めかけるも、やれるところまでやろうと、やりたいことを絞って再開。SPRESENSEの強みであるNeural Network Console(NNC)を用いた画像認識と、ELTRESを用いた通信に集中することにしました。
NNCに関しては、最初の認識率が30%程度で心が折れそうになったものの、Image Augmentation、Random Shiftを取り入れたり、Epoch数を増やしたりして、最終的には98.3%になり、思わずガッツポーズしてしまいました。学生時代にニューラルネットワークの一種の自己組織化マップをMATLAB上で苦労して使ったことがあったので、こんなに簡単にニューラルネットワークを構築出来るなんて、便利な時代だと思いました。また、それをマイコンボードに簡単に実装出来るにも驚きました。世界中の技術者がこのボードを用いて実装すれば、画像認識がより身近なものになると思いました。
ELTRESに関しては、ブラウザ上で見られる株式会社クレスコ・デジタルテクノロジーズのCLIP Viewer LiteのGUIがすごく直感的でわかりやすく、Arduinoのサンプルコードも充実していたため、実装がとても簡単でした。自動できれいに描画してくれるグラフも何となく使いたかったため、圧力センサの値の代わりに、認識結果を5ビットのバイナリーデータとして送りました。
SPRESENSEはソニーセミコンダクターソリューションズ株式会社の強みであるイメージセンサを軸とした、超低消費電力(0.7V)、6コアでの並列処理、ニューラルネットワークと、いろんな組み合わせで、強みを上手に発揮出来る構成となっているので、今後もいろいろと作って行きたいと思いました。
最後に、電子の目、イメージセンサのトランジスタ部で、フィルタ処理、特徴点抽出、ニューラルネットワークによる画像認識が、実時間並列処理で出来るようになっていけば、エッジ部だけで、高次元の視覚情報処理が行えるようになります。そんなことを考えるだけで、ワクワクしました。小さなイメージセンサに大きな可能性を感じました。
ソースコード
画像認識処理部
#include <Camera.h>
#include <RTC.h>
#include <Adafruit_ILI9341.h>
#include <DNNRT.h>
#include <SDHCI.h>
#include <BmpImage.h>
#include <EltresAddonBoard.h>
#define TFT_DC 9
#define TFT_CS 10
Adafruit_ILI9341 display = Adafruit_ILI9341(TFT_CS, TFT_DC);
#define OFFSET_X 43
#define OFFSET_Y 43
#define CLIP_WIDTH 112
#define CLIP_HEIGHT 112
#define DNN_WIDTH 28
#define DNN_HEIGHT 28
#define DNN_OUTPUT 6
#define TIME_HEADER 'T' // Header tag for serial time sync message
SDClass SD;
DNNRT dnnrt;
BmpImage bmp;
char fname[16];
DNNVariable input(DNN_WIDTH*DNN_HEIGHT);
const char label[DNN_OUTPUT] = {'0','1','2','3','4',' '};
const String strLabel[DNN_OUTPUT] = {"CUP","CHOPSTICKS","MASK","FILE","BOTTLE"," "};
bool isRegognized[DNN_OUTPUT] = {false};
uint8_t selected = 0;
// ボタン用ピンの定義
#define BUTTON4 4
#define BUTTON5 5
#define BUTTON6 6
#define BUTTON7 7
// ボタン押下時に呼ばれる割り込み関数
bool bButtonPressed = false;
bool bUpPressed = false;
bool bOkPressed = false;
bool bDownPressed = false;
void changeState() {
bButtonPressed = true;
}
void up(){
bUpPressed = true;
Serial.println("UP in the function");
}
void ok(){
bOkPressed = true;
}
void down(){
bDownPressed = true;
}
bool isStreaming = false;
void CamCB(CamImage img) {
if (!img.isAvailable()) {
Serial.println("Image is not available. Try again");
return;
}
// カメラ画像の切り抜きと縮小
CamImage small;
CamErr err = img.clipAndResizeImageByHW(small
, OFFSET_X, OFFSET_Y
, OFFSET_X + CLIP_WIDTH -1
, OFFSET_Y + CLIP_HEIGHT -1
, DNN_WIDTH, DNN_HEIGHT);
if (!small.isAvailable()){
putStringOnLcd("Clip and Reize Error:" + String(err), ILI9341_RED);
return;
}
// 推論処理に変えて学習データ記録ルーチンに置き換え
// 学習用データのモノクロ画像を生成
uint16_t* imgbuf = (uint16_t*)small.getImgBuff();
uint8_t grayImg[DNN_WIDTH*DNN_HEIGHT];
for (int n = 0; n < DNN_WIDTH*DNN_HEIGHT; ++n) {
grayImg[n] = (uint8_t)(((imgbuf[n] & 0xf000) >> 8)
| ((imgbuf[n] & 0x00f0) >> 4));
}
// 処理結果のディスプレイ表示
img.convertPixFormat(CAM_IMAGE_PIX_FMT_RGB565);
uint16_t* imgBuf = (uint16_t*)img.getImgBuff();
drawBox(imgBuf, OFFSET_X, OFFSET_Y, CLIP_WIDTH, CLIP_HEIGHT, 5, ILI9341_RED);
drawGrayImg(imgBuf, grayImg);
display.drawRGBBitmap(110, 10, (uint16_t *)img.getImgBuff(), 200, 200);
// 認識用モノクロ画像をDNNVariableに設定
uint16_t* img_dnn_buf = (uint16_t*)small.getImgBuff();
float *dnnbuf = input.data();
for (int n = 0; n < DNN_HEIGHT*DNN_WIDTH; ++n) {
// YUV422の輝度成分をモノクロ画像として利用
// 学習済モデルの入力に合わせ0.0-1.0に正規化
dnnbuf[n] = (float)(((img_dnn_buf[n] & 0xf000) >> 8)
| ((img_dnn_buf[n] & 0x00f0) >> 4))/255;
}
// 推論の実行
dnnrt.inputVariable(input, 0);
dnnrt.forward();
DNNVariable output = dnnrt.outputVariable(0);
int index = output.maxIndex();
// 推論結果の表示
String gStrResult;
if (index < 6) {
gStrResult = String(label[index]) + String(" ") + String(strLabel[index])
+ String(":") + String(output[index]);
} else {
gStrResult = String("Error");
}
Serial.println(gStrResult);
// 95%を超えると判定確定
if(output[index] > 0.95){
putStringOnLcd(String(strLabel[index] + String(":") + String(output[index])), ILI9341_RED);
isRegognized[index] = true;
}
else{
putStringOnLcd(String(strLabel[index] + String(":") + String(output[index])), ILI9341_GREEN);
}
}
void setup() {
setup1();
Serial.begin(115200);
while (!SD.begin()) {Serial.println("Insert SD card");}
// SDカードにある学習済モデルの読み込み
File nnbfile = SD.open("model.nnb");
// 学習済モデルでDNNRTを開始
dnnrt.begin(nnbfile);
RTC.begin();
RtcTime compiledDateTime(__DATE__, __TIME__);
RTC.setTime(compiledDateTime);
display.begin();
display.setRotation(3);
display.fillScreen(ILI9341_BLACK);
theCamera.begin(1, CAM_VIDEO_FPS_5, 200, 200, CAM_IMAGE_PIX_FMT_YUV422, 7); // カメラの開始
isStreaming = true;
theCamera.startStreaming(isStreaming, CamCB);
attachInterrupt(digitalPinToInterrupt(BUTTON4), up, FALLING);
attachInterrupt(digitalPinToInterrupt(BUTTON5), changeState, FALLING);
attachInterrupt(digitalPinToInterrupt(BUTTON6), ok, FALLING);
attachInterrupt(digitalPinToInterrupt(BUTTON7), down, FALLING);
updateClock();
}
void loop() {
loop1();
// Synchronize with the PC time
if (Serial.available()) {
if(Serial.find(TIME_HEADER)) {
uint32_t pctime = Serial.parseInt();
RtcTime rtc(pctime);
RTC.setTime(rtc);
}
}
updateClock();
if(bUpPressed){
selected ++;
if(selected == DNN_OUTPUT-1){
selected = 0;
}
Serial.println("UP in loop");
bUpPressed = false;
}
if(bOkPressed){
isRegognized[selected] = isRegognized[selected] ^ true;
bOkPressed = false;
}
if(bDownPressed){
if(selected == 0){
selected = DNN_OUTPUT-1;
}
selected --;
bDownPressed = false;
}
sideButton();
delay(1000);
}```
```arduino:ディスプレイ処理部
// ディスプレイの縦横の大きさ
#define DISPLAY_WIDTH 200
#define DISPLAY_HEIGHT 200
// 記録した学習データ用画像を表示する位置
#define GRAY_OFFSET_X 163
#define GRAY_OFFSET_Y 163
// 液晶ディスプレイの下部に文字列を表示する
void putStringOnLcd(String str, int color) {
int len = str.length();
display.setTextSize(2);
int sx = 205 - len/2*12;
if (sx < 0) sx = 0;
display.fillRect(100, 224, 320, 240, ILI9341_BLACK);
display.setCursor(sx, 225);
display.setTextColor(color);
display.println(str);
}
// 液晶ディスプレイにLINE_THICKNESSの太さの四角形を描画する
void drawBox(uint16_t* imgBuf, int offset_x, int offset_y, int width, int height, int thickness, int color) {
/* Draw target line */
for (int x = offset_x; x < offset_x+width; ++x) {
for (int n = 0; n < thickness; ++n) {
*(imgBuf + DISPLAY_WIDTH*(offset_y+n) + x) = color;
*(imgBuf + DISPLAY_WIDTH*(offset_y+height-1-n) + x) = color;
}
}
for (int y = offset_y; y < offset_y+height; ++y) {
for (int n = 0; n < thickness; ++n) {
*(imgBuf + DISPLAY_WIDTH*y + offset_x+n) = color;
*(imgBuf + DISPLAY_WIDTH*y + offset_x + width-1-n) = color;
}
}
}
// 生成した学習データ用画像を表示する
void drawGrayImg(uint16_t* imgBuf, uint8_t* grayImg) {
int j = 0;
for (int y = GRAY_OFFSET_Y; y < GRAY_OFFSET_Y + DNN_HEIGHT; ++y, ++j) {
int i = 0;
for (int x = GRAY_OFFSET_X; x < GRAY_OFFSET_X + DNN_WIDTH; ++x, ++i) {
uint16_t gray8 = grayImg[j*DNN_WIDTH + i];
uint16_t gray16 = ((gray8 & 0xf8) << 8)
| ((gray8 & 0xfc) << 3)
| ((gray8 & 0xf8) >> 3);
*(imgBuf + DISPLAY_WIDTH*y + x) = gray16;
}
}
drawBox(imgBuf, GRAY_OFFSET_X, GRAY_OFFSET_Y, DNN_WIDTH, DNN_HEIGHT, 3, ILI9341_GREEN);
}
void printClock(RtcTime &rtc)
{
printf("%04d/%02d/%02d %02d:%02d:%02d\n",
rtc.year(), rtc.month(), rtc.day(),
rtc.hour(), rtc.minute(), rtc.second());
display.setCursor(0, 225);
display.setTextColor(ILI9341_WHITE);
display.fillRect(0,224, 100, 240, ILI9341_BLACK);
display.printf(" %02d:%02d", rtc.hour(), rtc.minute());
}
void updateClock()
{
static RtcTime old;
RtcTime now = RTC.getTime();
if (now != old) {
printClock(now);
old = now;
}
}
void sideButton(){
display.fillRect(0, 10, 70, 50, ILI9341_NAVY);
display.fillRect(0, 60, 70, 50, ILI9341_NAVY);
display.fillRect(0, 110, 70, 50, ILI9341_NAVY);
display.fillRect(0, 160, 70, 50, ILI9341_NAVY);
display.drawRect(0, 10, 70, 50, ILI9341_WHITE);
display.drawRect(0, 60, 70, 50, ILI9341_WHITE);
display.drawRect(0, 110, 70, 50, ILI9341_WHITE);
display.drawRect(0, 160, 70, 50, ILI9341_WHITE);
display.setCursor(22, 27);
display.setTextSize(2);
display.setTextColor(ILI9341_WHITE);
display.println("UP");
display.setCursor(6, 77);
display.setTextSize(2);
display.setTextColor(ILI9341_WHITE);
display.setTextSize(2);
display.setTextColor(ILI9341_WHITE);
if(strLabel[selected].length() == 3){
display.setCursor(18, 77);
display.setTextSize(2);
}
else if(strLabel[selected].length() == 4){
display.setCursor(11, 77);
display.setTextSize(2);
}
else{
display.setCursor(5, 80);
display.setTextSize(1);
}
if(isRegognized[selected]){
display.setTextColor(ILI9341_WHITE);
}
else{
display.setTextColor(ILI9341_RED);
}
display.println(strLabel[selected]);
display.setCursor(22, 127);
display.setTextSize(2);
display.setTextColor(ILI9341_WHITE);
display.println("OK");
display.setCursor(11, 177);
display.setTextSize(2);
display.setTextColor(ILI9341_WHITE);
display.println("DOWN");
}
ELTRES通信部
// PIN定義:LED(プログラム状態)
#define LED_RUN PIN_LED0
// PIN定義:LED(GNSS電波状態)
#define LED_GNSS PIN_LED1
// PIN定義:LED(ELTRES状態)
#define LED_SND PIN_LED2
// PIN定義:LED(エラー状態)
#define LED_ERR PIN_LED3
// プログラム内部状態:初期状態
#define PROGRAM_STS_INIT 0
// プログラム内部状態:起動中
#define PROGRAM_STS_RUNNING 1
// プログラム内部状態:終了
#define PROGRAM_STS_STOPPED 3
// プログラム内部状態
int program_sts = PROGRAM_STS_INIT;
// GNSS電波受信タイムアウト(GNSS受信エラー)発生フラグ
bool gnss_recevie_timeout = false;
// 点滅処理で最後に変更した時間
uint64_t last_change_blink_time = 0;
// イベント通知での送信直前通知(5秒前)受信フラグ
bool event_send_ready = false;
// ペイロードデータ格納場所
uint8_t payload[16];
// 最新値
float value = 0;
void eltres_event_cb(eltres_board_event event) {
switch (event) {
case ELTRES_BOARD_EVT_GNSS_TMOUT:
// GNSS電波受信タイムアウト
Serial.println("gnss wait timeout error.");
gnss_recevie_timeout = true;
break;
case ELTRES_BOARD_EVT_IDLE:
// アイドル状態
Serial.println("waiting sending timings.");
digitalWrite(LED_SND, LOW);
break;
case ELTRES_BOARD_EVT_SEND_READY:
// 送信直前通知(5秒前)
Serial.println("Shortly before sending, so setup payload if need.");
event_send_ready = true;
break;
case ELTRES_BOARD_EVT_SENDING:
// 送信開始
Serial.println("start sending.");
digitalWrite(LED_SND, HIGH);
break;
case ELTRES_BOARD_EVT_GNSS_UNRECEIVE:
// GNSS電波未受信
Serial.println("gnss wave has not been received.");
digitalWrite(LED_GNSS, LOW);
break;
case ELTRES_BOARD_EVT_GNSS_RECEIVE:
// GNSS電波受信
Serial.println("gnss wave has been received.");
digitalWrite(LED_GNSS, HIGH);
gnss_recevie_timeout = false;
break;
case ELTRES_BOARD_EVT_FAULT:
// 内部エラー発生
Serial.println("internal error.");
break;
}
}
void setup1() {
// シリアルモニタ出力設定
Serial.begin(115200);
// LED初期設定
pinMode(LED_RUN, OUTPUT);
digitalWrite(LED_RUN, HIGH);
pinMode(LED_GNSS, OUTPUT);
digitalWrite(LED_GNSS, LOW);
pinMode(LED_SND, OUTPUT);
digitalWrite(LED_SND, LOW);
pinMode(LED_ERR, OUTPUT);
digitalWrite(LED_ERR, LOW);
// ELTRES起動処理
eltres_board_result ret = EltresAddonBoard.begin(ELTRES_BOARD_SEND_MODE_1MIN,eltres_event_cb, NULL);
if (ret != ELTRES_BOARD_RESULT_OK) {
// ELTRESエラー発生
digitalWrite(LED_RUN, LOW);
digitalWrite(LED_ERR, HIGH);
program_sts = PROGRAM_STS_STOPPED;
Serial.print("cannot start eltres board (");
Serial.print(ret);
Serial.println(").");
return;
}
// 正常
program_sts = PROGRAM_STS_RUNNING;
}
void loop1() {
switch (program_sts) {
case PROGRAM_STS_RUNNING:
// プログラム内部状態:起動中
if (gnss_recevie_timeout) {
// GNSS電波受信タイムアウト(GNSS受信エラー)時の点滅処理
uint64_t now_time = millis();
if ((now_time - last_change_blink_time) >= 1000) {
last_change_blink_time = now_time;
bool set_value = digitalRead(LED_ERR);
bool next_value = (set_value == LOW) ? HIGH : LOW;
digitalWrite(LED_ERR, next_value);
}
} else {
digitalWrite(LED_ERR, LOW);
}
if (event_send_ready) {
// 送信直前通知時の処理
event_send_ready = false;
value = 0;
for (uint8_t i=0; i < DNN_OUTPUT; i++){
if(i==0){
value += isRegognized[i];
}
else if(i==1){
value += isRegognized[i] * 10;
}
else if(i==2){
value += isRegognized[i] * 100;
}
else if(i==3){
value += isRegognized[i] * 1000;
}
else if(i==4){
value += isRegognized[i] * 10000;
}
}
setup_payload(value);
// 送信ペイロードの設定
EltresAddonBoard.set_payload(payload);
}
break;
case PROGRAM_STS_STOPPED:
// プログラム内部状態:終了
break;
}
}
void setup_payload(float value) {
// 設定情報をシリアルモニタへ出力
Serial.print("[setup_payload]");
Serial.print("value:");
Serial.print(value, 6);
Serial.println();
// ペイロード領域初期化
memset(payload, 0x00, sizeof(payload));
// ペイロード種別[気圧圧力照度距離ペイロード]設定
payload[0] = 0x85;
// 圧力部を用いて、通信
uint32_t raw;
raw = *((uint32_t*)&value);
payload[5] = (uint8_t)((raw >> 24) & 0xff);
payload[6] = (uint8_t)((raw >> 16) & 0xff);
payload[7] = (uint8_t)((raw >> 8) & 0xff);
payload[8] = (uint8_t)((raw >> 0) & 0xff);
}