kou が 2022年09月26日05時16分00秒 に編集
初版
タイトルの変更
Spresenseでプラレール衝突防止システム
タグの変更
SPRESENSE
プラレール
NNC
画像認識
メイン画像の変更
記事種類の変更
製作品
ライセンスの変更
(MIT) The MIT License
本文の変更
# デモ動画 @[youtube](https://youtu.be/9j7SetNHvoE) # 概要 プラレールの車体の先頭にはカメラが取り付けられており、画像認識によって前方に障害物がある場合には減速して停止するようになっています。 # なぜ作ったのか? 近年では鉄道業界での人手不足が深刻化して、人手不足により運転手に負荷がかかることで事故も発生しています。そこで、こうした列車の運転をする際に前方の障害物や危険な状態などを発見して自動ブレーキなどにより運転手をサポートできるようなシステムを作れないかなと思い立って、今回はプラレールとSpresenseを使ってそんなシステムを実際に作ってみました! 将来的にはこれを実際に列車に導入して、列車運行の負担を減らすことができれば少しでも地方のローカル線などを廃線から救えるんじゃないかとも妄想しています。 # 運行支援システムの全体像 運行支援システムのプラレール版としては以下の図のような構成を考えています。 これすべてを一度に実装するのは難しいので、今回は赤枠で囲った部分(Eltressの部分を除く)で障害物の検知と自動停止までを実装しようと思います。  # 部品 | 部品名 | 価格| 個数 | |:---:|:---:|:--:| | プラレール(成田エクスプレス) | ¥2,640 |1 | | Spresenseメインボード[提供品] | ¥6,050 | 1| | Spresense拡張ボード[提供品] | ¥3,850 | 1 | | Spresenseカメラボード[提供品] | ¥3,850 | 1 | | Mic&LCD KIT for SPRESENSE | ¥7,600 | 1| | MicroSDCard 16GB [提供品] | ¥620 | 1 | | リポバッテリー 860mAh | ¥1,480 | 1 | | DRV8835 モータドライバモジュール | ¥450 | 1 | | 単三x2 電池ボックス | ¥70 | 1| | ユニバーサル基板 | ¥30 | 1 | | 小型リチウムイオン電池充電器 | ¥980| 1 | | PHコネクタ用ベース付きポスト | ¥10| 1 | | PHコネクタ用ベース付きポスト | ¥10| 1 | |SPRESENSE-WiSUN-EVK-701[提供品]|¥6,890|1| |WiSUN USBドングル BP35C2|¥15,800|1| | 合計 | ¥5,0320 || プラレールなどはお好きなものを選択してください。私は個人的に成田エクスプレスが好きなので選びましたが、改造をするなら連結仕様ではない車両の方がスペースが広いのでオススメです。 基本的にプラレールのシャーシ部分は共通なので同じ方法で改造できると思いますが、例えば京王線の車両などの特殊な車両は一部シャーシが違うので注意が必要です。 改めて金額を見直してみるとすごい額がかかっていますね。今回モニター品を提供してくださった企業様に感謝です! # 加工 機械学習のために学習データを集める必要がありますが、まずは肝心の車体がないことには何も始まりません。とゆうわけで早速プラレールに加工をしていきましょう! 今回購入したプラレールでは、3両編成なのですがこんな感じの構成になっています。  今回の構成として先頭部分にカメラとSpresenseを取り付けて、中間車にバッテリーを内蔵する構成にしようと思います。 ## 先頭車両 先頭車両の加工としては車両の前方にカメラ用の穴と、車両の上部にカメラケーブルを通すための穴を開けます。また、前の方にある柱も邪魔になるので除去しておきます。 加工する際には、お手元のボール盤やプラスチックカッター、ホットナイフやニッパーなどの工具を活用してください。    ## 中間車両 中間車両は中心にある柱が中に色々と収める時に邪魔なので除去します。 柱を除去するときは、ニッパーなどで大まかに切り取ってからやすりで整形してあげると良い感じになります。また、内部の中央にある出っ張りも電池ボックスを収納する邪魔になるので除去します。  ## モーター部分の加工 プラレールのモーターを外部電源を用いて制御できるように、モーターに電線を取り付けます。 先頭車両の部分の黒いカバーを取り外すと内部にモーターがあるので、このモーターの両端に電線をハンダ付けしてあげます。 カバーを取り付ける際に電線と干渉するので適宜カットしてあげてください。   ## 電池ボックス 使用する電池ボックスに電線をハンダ付けしてあげます。完全に内部に収納されるようなタイプの電池ボックスを選んでしまうと入りきらないので、このように外側に出るようなタイプを選んでください。  ## モータードライバ 今回はDRV8835というモータドライバのモジュールを使用したので基盤に適宜ハンダ付けしてあげます。秋月の説明書に参考の回路例が載っているので従って配線をしてあげます。 注意点としてこのドライバはモータ電源電圧が2V以上じゃないと駆動しないので、単三1つだけだと駆動しません。 本来ならちゃんとしたコネクタを用意してあげたかったのですが、今回は時間がなかったので電線直付け+ピンヘッダ接続にしました。接触不良が怖いのでグルーガンでがちがちに固めましたが、さすがにひどすぎるので今後改良しようと思います。  ## Spresenseの改造 今回はSpresenseをプラレールの車体に搭載するため、リポバッテリーで電源供給します。ただし、Spresenseはそのままでは外部給電できないので写真のようにコネクタ付近にPHコネクタを取り付けておきます。 なお、この作業をすると保証対象外になるので注意してください。  # 画像認識 画像認識に関してはSONYさんが以下のような分かりやすいチュートリアル動画を用意してくれているのでとても参考になりました。 @[youtube](https://www.youtube.com/watch?v=nJB6IY9qDiA&list=PLzgPwCLYLGPOau4b4_2NPjKUVa1l1OhK3&index=11) ## 学習データの収集 学習に必要な最低限のハードウェアは用意できたのでさっそく学習をするためのデータを用意していきます。学習をする際にモータドライバはなくても大丈夫です。 今回はこのように一周するようなコースを用意しました。  今回は線路上の障害物の例として野生のシカが飛び出してくるという場合を想定して、こんなかんじの鹿さんのスタンドを用意してあげました。 まぁプラレールの縮尺で考えると、モンスターレベルの鹿ですが(笑)  Spresenseのスケッチ例の1つに[Camera]の`camera.ino`がありますのでこれを利用します。学習用に多くの画像を一度に取得したいので`TOTAL_PICTURE_COUNT`を`100`に、撮影のインターバルをなるべく小さくするため185行目にある`sleep(1);`をコメントアウトします。 MicroSDカードを挿入した拡張ボードを車体の上に固定して、レールの上を障害物がある状態・ない状態でそれぞれ50枚以上のデータが取れるように車両を周回させました。 @[youtube](https://youtu.be/BLDEi29NYt4) ## データセットの作成 実際にカメラでは以下のような写真を撮影することができました。  一部車両が横転していたり、あまりにもぼけている画像などを除いてあげて今回はおよそ170枚程度の学習データを用意しました。 写真は障害物が画像に写っているかどうかで分類して、0(鹿がいない)と1(鹿がいる)という2つのフォルダに分けて保存してあげて、以下の構成のような`train_image`が完成します。 ``` train_image ├─1 └─0 ``` 次にNeural Network Console上でデータセットを作成してきます。 [データセットの作成]から[Image Classification]を選択します。  ウインドウでソースディレクトリに先ほど作成したフォルダを指定して、出力ディレクトリでデータセットの保存先を指定します。 出力カラーチャンネルは、画像をモノクロで学習させるばあいは1、カラーで学習させる場合は3を選択します。出力幅と高さは今回は`100x100`にしましたが精度の良い学習をするには、この画像サイズも工夫する必要がありそうです。 一番下で`train.csv`と`test.csv`の2つがありますが、これは学習用のデータと検証用のデータです。今回は元データを80%と20%の割合で分けてデータセットを作成しました。  成功すると作成したデータセットが正しく表示されていると思います。 ## モデルの作成と学習 実際にモデルを作成していくわけですが、ここでは先ほど紹介したチュートリアルの数字認識のモデルをカラー用に少しだけ変更して学習します。変更点としてはRGBになるので入力が`3x100x100`になるのと、出力が鹿がいるかいないかの2つなのでAffine_2のoutputを`2`に変更してあげます。 正直なところ私自身もあまり機械学習の仕組みはよく理解していないので、なぜこのモデルで動くのかはあまり理解できていません。逆にそれでも使えているのがNNCのすごいところですが。 機械学習周りに関しては[SonyNeuralNetworkConsole公式チャンネル](https://www.youtube.com/c/NeuralNetworkConsole)の方で詳しい解説やチュートリアルがあるので参考になります。 モデルの作成をしたら、右上のデータセットで先ほど作成したデータセットを選択して学習してあげます。機械学習なので高性能なGPUを搭載した環境の方が速く学習が進みます。 学習する際にバッチサイズのエラーが発生する場合は右上のコンフィグからバッチサイズを入力するデータセットの枚数に変更してあげてください。  学習を実行すると学習曲線がリアルタイムで描画されていきます。機械学習には詳しくないのでSONYさんの動画によると、この赤色の実線と点線が離れることなく収束していくと良い感じに学習できているようです。 うまく学習ができていないときはいい感じにパラーメーターを調整してあげたり、コンフィグから構造自動探索を試してみてください。正しく学習をする方法はいろいろな理論やテクニックがあるようですが、私はよくわからなかったので評価が良ければそれで良いやという方針で進めていました。精度を求めるならばちゃんと調整をする必要がありそうです。  ## 学習結果の評価とモデルの書き出し 学習が完了すると評価を実行できるので実行します。 こうすると、評価用の画像に対して実際にモデルを適用して実験できます。ここで`y_0`は鹿がいない確率、`y_1`は鹿がいる確率なのでラベルと合っているか確認しましょう。  混同行列を選択してあげると以下のような感じで何枚のデータがどのように認識されているかが分かります。ポイントは右下のラインに値がありそれ以外の部分(誤って認識されている)がない状態だと良いです。  確認出来たら評価結果を右クリックしてNNB形式でエクスポートし、`model.nnb`をmicroSDカードに保存してあげます。  # 画像認識の実験 SONY公式チュートリアルからサンプルプログラムをダウンロードします。[Github](https://github.com/TE-YoshinoriOota/Spresense-Tech-Seminar-Basic)からSpresense_image_recognition_projectのSpresense_number_recognitionがプロジェクトです。 また、LCDライブラリ用にSpresense用にカスタマイズされた[Adafruit_ILI9341](https://github.com/kzhioki/Adafruit_ILI9341)と[Adafruit-GFX-Library](https://github.com/kzhioki/Adafruit-GFX-Library)をダウンロードします。 次にサンプルプログラムを今回作成したモデルに合うように以下のように変更してあげます。 主な変更点としては、RGB用に画像入力を変更しています。 ```arduino:recogtest.ino /* * Spresense_gnss_simple.ino - Simplified gnss example application * Copyright 2019-2021 Sony Semiconductor Solutions Corporation * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 2.1 of the License, or (at your option) any later version. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library; if not, write to the Free Software * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA */ #include <Camera.h> #include <SPI.h> #include <EEPROM.h> #include <DNNRT.h> #include "Adafruit_ILI9341.h" #include <SDHCI.h> SDClass theSD; /* LCD Settings */ #define TFT_RST 8 #define TFT_DC 9 #define TFT_CS 10 #define DNN_IMG_W 28 #define DNN_IMG_H 28 #define CAM_IMG_W 320 #define CAM_IMG_H 240 #define CAM_CLIP_X 48 #define CAM_CLIP_Y 0 #define CAM_CLIP_W 224 #define CAM_CLIP_H 224 #define LINE_THICKNESS 5 Adafruit_ILI9341 tft = Adafruit_ILI9341(&SPI, TFT_DC, TFT_CS, TFT_RST); uint8_t buf[DNN_IMG_W*DNN_IMG_H]; DNNRT dnnrt; DNNVariable input(3*DNN_IMG_W*DNN_IMG_H); static uint8_t const label[2] = {0, 1}; void putStringOnLcd(String str, int color) { int len = str.length(); tft.fillRect(0,224, 320, 240, ILI9341_BLACK); tft.setTextSize(2); int sx = 160 - len/2*12; if (sx < 0) sx = 0; tft.setCursor(sx, 225); tft.setTextColor(color); tft.println(str); } void drawBox(uint16_t* imgBuf) { /* Draw target line */ for (int x = CAM_CLIP_X; x < CAM_CLIP_X+CAM_CLIP_W; ++x) { for (int n = 0; n < LINE_THICKNESS; ++n) { *(imgBuf + CAM_IMG_W*(CAM_CLIP_Y+n) + x) = ILI9341_RED; *(imgBuf + CAM_IMG_W*(CAM_CLIP_Y+CAM_CLIP_H-1-n) + x) = ILI9341_RED; } } for (int y = CAM_CLIP_Y; y < CAM_CLIP_Y+CAM_CLIP_H; ++y) { for (int n = 0; n < LINE_THICKNESS; ++n) { *(imgBuf + CAM_IMG_W*y + CAM_CLIP_X+n) = ILI9341_RED; *(imgBuf + CAM_IMG_W*y + CAM_CLIP_X + CAM_CLIP_W-1-n) = ILI9341_RED; } } } void CamCB(CamImage img) { if (!img.isAvailable()) { Serial.println("Image is not available. Try again"); return; } CamImage small; CamErr err = img.clipAndResizeImageByHW(small , CAM_CLIP_X, CAM_CLIP_Y , CAM_CLIP_X + CAM_CLIP_W -1 , CAM_CLIP_Y + CAM_CLIP_H -1 , DNN_IMG_W, DNN_IMG_H); if (!small.isAvailable()){ putStringOnLcd("Clip and Reize Error:" + String(err), ILI9341_RED); return; } small.convertPixFormat(CAM_IMAGE_PIX_FMT_RGB565); uint16_t* temp = (uint16_t*)small.getImgBuff(); // float *dnnbuf = input.data(); // float f_max = 0.0; // for (int n = 0; n < DNN_IMG_H*DNN_IMG_W; ++n) { // dnnbuf[n] = (float)((tmp[n] & 0x07E0) >> 5); // if (dnnbuf[n] > f_max) f_max = dnnbuf[n]; // } // /* normalization */ // for (int n = 0; n < DNN_IMG_W*DNN_IMG_H; ++n) { // dnnbuf[n] /= f_max; // } float* r = input.data(); float* g = r + DNN_IMG_W * DNN_IMG_H; float* b = g + DNN_IMG_W * DNN_IMG_H; for (int i = 0; i < DNN_IMG_W * DNN_IMG_H; ++i) { *(r++) = (float)((*temp >> 11) & 0x1F)/ 31.0; // 0x1F = 31 *(g++) = (float)((*temp >> 5) & 0x3F)/ 63.0; // 0x3F = 64 *(b++) = (float)((*temp) & 0x1F)/ 31.0; // 0x1F = 31 ++temp; } String gStrResult = "?"; dnnrt.inputVariable(input, 0); dnnrt.forward(); DNNVariable output = dnnrt.outputVariable(0); int index = output.maxIndex(); if (index < 2) { gStrResult = String(label[index]) + String(":") + String(output[index]); } else { gStrResult = String("?:") + String(output[index]); } Serial.println(gStrResult); img.convertPixFormat(CAM_IMAGE_PIX_FMT_RGB565); uint16_t* imgBuf = (uint16_t*)img.getImgBuff(); drawBox(imgBuf); tft.drawRGBBitmap(0, 0, (uint16_t *)img.getImgBuff(), 320, 224); putStringOnLcd(gStrResult, ILI9341_YELLOW); } void setup() { Serial.begin(115200); tft.begin(); tft.setRotation(3); while (!theSD.begin()) { putStringOnLcd("Insert SD card", ILI9341_RED); } File nnbfile = theSD.open("model.nnb"); int ret = dnnrt.begin(nnbfile); if (ret < 0) { putStringOnLcd("dnnrt.begin failed" + String(ret), ILI9341_RED); return; } theCamera.begin(); theCamera.startStreaming(true, CamCB); } void loop() { } ``` 実際にこのプログラムをSpresenseに書き込んで拡張ボードに`model.nnb`を入れたmicroSDカードを挿入してLCDディスプレイを接続します。 すると以下のようにディスプレイに認識率が表示されます。学習に使用した障害物をカメラに映したときに値が変化するかなど試してみてください。  # プラレールに組み込む 認識ができたら実際にプラレールに組み込んでモータの制御を行ってみます。 モータドライバの各ピンに対して以下の対応表の通りに接続します。 | モータドライバ | 拡張ボード | |:---:|:---| | AIN1 | 3 | | AIN2 | GND | | VCC | 3.3V | | GND | GND | プラレールは構造上、後進しようとしても脱線してしまうため今回は正転だけにしました。 本来ならちゃんと基盤も収めたかったのですが、SDカード用の基板を作る余裕がなかったため拡張ボードを使用したのでこうなりました。固定がグルーガンとマスキングテープというひどい状態ですが一応の動作確認はできるので大丈夫です。  次にソフトウェアに変更を加えます。131行目`int index = output.maxIndex();`の下の行に以下のコードを追記してください。 ```arduino:追記するコード if (output[1] > 0.8) { analogWrite(3, 0); } else { analogWrite(3, 130); } ``` `output[1]`には`float`で障害物がある確率(0~1)が代入されているので、これが0.8以上であればモータの速度を0にするとう処理にしています。それ以外の場合だと`analogWrite`で130のスピードで進むようにしています。現状だと、これが速すぎると障害物を認識することができないのでスピードは比較的ゆっくり目に設定しています。 これを実際に動かすとこんな感じで一応検知すると止まるようになってます。 なお、この映像では**カラー**ではなく**モノクロ**で学習したモデルを使用しています。 時間の都合上、カラーモデルは実際に列車での動作確認は未検証です。 @[youtube](https://youtu.be/9j7SetNHvoE) ## 問題点 映像ではうまく動いていますがけっこう誤検知も多かったりします。これはモデルの品質に影響してそうです。また、障害物がない状態でも直線になると停止してしまうことがあるため、特徴として直線を障害物として認識しまっているのではないかということもあるので、もっと様々な状況で障害物を学習する必要がありそうです。 # WiSUNモジュールへの挑戦 今回の列車支援システムでは障害物を検知して自動停止するという部分だけでしたが、実際に速度制御をしながらそのアシストとして機能させていなぁと思いました。 そこで、Elchikaで[airpocketさんの記事](https://elchika.com/article/bb1bf833-6bd7-4c95-923f-93cf5239ef05/)を参考にして実際にデータをWiSUNモジュールから送信する部分まではテストできましたが、逆にドングルから送信する部分のテストが間に合わずに今回は断念しました。 WiSUNを使った感想として、ネットワークとしてちゃんと認証基盤などが整っており、通信プロトコルにもUDPを使用しており、アドレスにIPv6を使っていたことから小さなネットワークを構築できるようになっているという部分は面白いと思いました。これを使えば映像のデータ転送なども実現できそうです。逆にWiSUNモジュールに関する情報が少なくてなかなか悩むポイントも多かったので、通信部分がWrapされてもっと手軽に使えるようになればいろいろと使いどころはありそうです。(あと値段も高いなぁ...)  今後は実際にWiSUNモジュールを使った速度制御まで実装したいと思ってます。 # 感想 列車の運行支援システムを実装するという目標に対して、Spresenseを用いた障害物検知の部分までは今回実装することができました。今後は目標に向けてEltresなどLPWAヘの連携などいろいろと機能開発をしていこうと思います。 また、Spresenseという小さなマイコンでここまで手軽に画像認識ができるのはとても面白いなぁと思います。以前からSpresenseを見ていて、ここまで小型で高性能ならもっといろいろな用途に使えそうだなと思いましたがメインボード単体でもかなりの値段がするのでなかなか電子工作で手が出しずらかったので、今回提供していただき実際に触れることができとても楽しかったです。