airpocketのアイコン画像
airpocket 2024年01月29日作成 (2024年01月30日更新) © MIT
製作品 製作品 閲覧数 1185
airpocket 2024年01月29日作成 (2024年01月30日更新) © MIT 製作品 製作品 閲覧数 1185

たまごAI

たまごAI

はじめに

SORA-Q(LEV-2)によるSLIM撮影ミッションの大成功、おめでとうございます!
このプロジェクトはSORA-Qと同じく「世界初」を目指して立ち上げました。
SPRESENSEを使って(たぶん)世界初のたまごの分類専用AI(役には立たない)の開発記録です。

この装置が実現するのは、生たまごとゆでたまごの2クラス分類です。エクストラサクセスとして、生たまご、ゆでたまご、温泉たまごの3クラス分類にも挑戦します。

実現のための手法

テーブルの上でたまごに回転運動を与えるとゆでたまごは回り続け、生たまごはすぐに停止します。
これは、ゆでたまごは白身や黄身に運動エネルギーが伝わりやすく大きなエネルギーを受け取ってまわり続けるのに対し、生たまごは中身が流動してエネルギーが伝わらず損失も大きいためです。

この現象を利用し、モーターを使ってたまごに回転加速度を与えた際の消費電力を測定することで、たまごの内部状態を推定することを狙いました。

閾値を使った判定も可能ですが、「自分だけしか持っていないAIモデルを作ってみたかった!」ため、Neural Network Consoleを使って[生たまご][ゆてたまご]のクラス分類を行うモデルを制作しました。

判定用のデータには、回転時のモーター電流を測定して用いています。ステッピングモータでたまごを加減速しながら回転させた際の電流値を1024回サンプリングしてFFTし、512次元のベクトルデータとしてクラス分類を試みました。

いきなり完成

学習済みのモデルを使用して10個の生たまごと10個のゆでたまごの分類を行ったところ正答率は100%でした。


エクストラサクセスとして温泉たまごの検出をこころみましたが、これには失敗しています。
採取したデータを見ると分類可能な情報は内包しているように見えるため、AIの学習を工夫すれば分類できる可能性も十分あるのではないかと考えています。

また冒頭に書いた通りたまごに加速度を与えた際の回転トルクの減衰や位相のずれを見る予定でしたが、実査にはたまご内部の硬さ変化による固有振動数のシフトを観測しているように見受けられます。(もう少しデータを精査して考察が必要です)

最後の「おまけ」に詳述しますが、たまごの内部状態を把握するにはたまごとモーターをリジットに固定しS/N比の高い信号伝達ルートを確保することがもっとも大切なポイントでした。
開発の過程においては、モーターとたまごホルダー間、たまごホルダーとたまご間のそれぞれの固定が甘く、情報の多くが失われているという問題に気付くのが遅れて大いに悩まされました。
IoTシステムへのAIの実装にも同様の課題は重要なポイントであると思われ、今回の開発はとても良い経験になりました。

構成

装置の構成は下図の通りです。
ハードウェア構成

ステッピングモーターで卵を回し、その際の電流変化を読み取っています。
タイマーを使用せずそれなりに一定タイミングで測定するため、メインコアでセンシング、サブ1コアでモーター制御しています。

装置はディスプレイを含む本体の上にたまごホルダーが載っている構造です。

キャプションを入力できます

たまごホルダーはNema17 ステッピングモーターに直結して回転させています。
自作基板は、市販のDC-DCコンバータ、モータードライバ、電流センサを載せ、ディスプレイやSPRESENSEと接続するだけのシンプルな構成です。

キャプションを入力できます

電源は12V ACアダプタからモータードライバへ直接供給、SPRESENSEへはDC-DCコンバータで5Vに降圧して供給しました。

動作している様子


ディスプレイには、測定した電流値をFFT解析したグラフを表示しています。

学習用データ

データ採取時は規定の周波数でモーターを回転させつつ電流値を測定し、得られた電流データにFFT処理を行いmicroSDに保存します。
測定はゆでたまご10個、生卵10個、温泉たまご10個を用い、それぞれ100回ずつ合計3000回行いました。

次の図は、実際に測定した電流の変化の例です。電流値を正規化してグラフ表示しています。
電流測定結果
加速度が任意の周期の正弦波に一致する様、モーターを速度制御しています。また、ステッピングモーターはその構造上、1stepごとに短周期の加減速を繰り返しています。
大きな周期の正弦波にステッピングモータのステップ動作に由来する小さな周期の波が重畳しています。

ゆでたまご、生たまご、温泉たまごそれぞれについて同様の測定を行いFFT処理して周波数特性を比較したところ、100~120Hz付近の小さな周期の波がたまごの状態に伴う特長を多く保持していました。
電流値のFFT
生たまごには120Hz付近に特徴的なピークがありますが、ゆでたまごでは100Hz付近にシフトし、温泉たまごでは100~120Hzに幅広く存在しつつ全体的にピークが低くなっています。
詳しく調査できていませんが、生たまごは液状の白身が内部の運動状態との絶縁層となり、リジットに締結された「卵殻+たまごホルダー+モーターのローター」の一体物が120Hz付近に固有振動数を保有し共鳴、ゆで卵は固まった白身の弾性により低周波側にシフト、温泉たまごはゲル状の白身が緩衝材となって共鳴振動自体を緩和させた、、、のかもしれません。

学習について

得られたデータは512次元のベクトルデータとして扱い、Sony Neural Network Consoleで学習させました。初期のモデルは下図左の構造でしたが、自動探索機能を使用して精度の高い下図右のモデルへ変更しています。
自動探索前後のネットワークモデル

データの6割を学習用、残りをバリデーション用に用いています。
データサイズが小さいため、200世代の学習に必要な時間はCPUでおおむね10秒以下でした。

部品とコード

詳細はGit hubに(まだ整理できてませんが)公開しています。

部品表

名前 数量 備考
SPRESENSE メインボード 1 (switch science)[https://www.switch-science.com/products/3900]
SPRESENSE 拡張ボード 1 (switch science)[https://www.switch-science.com/products/3901]
ILI9341 2.2" TFT LCD 1 amazonなどから
A4988 ドライバボード 1 amazonなどから
Nema17 HS4023 1 秋月電子
M5Stack用電流計ユニット 1 (switch science)[https://www.switch-science.com/products/6739]
60mmx80mm ユニバーサル基板 1
M2x10 ボルト 8 基板固定用
M3x5 皿ボルト 4 ステッピングモーター固定用
M3x10 ボルト 6 トップパネル固定用
タクトスイッチ 1 秋月電子
φ2.1×φ5.5電源ジャック 1 秋月電子
XHコネクタ 2Pin オス/メス 1
XHコネクタ 3Pin オス/メス 1
XHコネクタ 4Pin オス/メス 2
XHコネクタ 5Pin オス/メス 1
QIコネクタ 2Pin オス 1
QIコネクタ 8Pin オス 1
CASING BASE 1 3Dプリンタ出力※
CASING TOP MOTOR 1 3Dプリンタ出力※
CASING TOP DISPLAY 1 3Dプリンタ出力※
EGG HOLDER BASE 1 3Dプリンタ出力※
EGG HOLDER TOP 1 3Dプリンタ出力※
12V2A ACアダプタ φ2.1×φ5.5センター+

3Dプリント用データはGit hubにて公開しています。

接続表

回路図書いてないので接続表でご勘弁を。。

SPRESENE拡張ボード ILI9341 A4988 Nema17 電流計 電源ジャック DC-DCコンバータ タクトスイッチ
AREF OUT(+)
3V3 3V3 VDD VCC
3V3 MS1
3V3 MS2
3V3 MS3
GND GND GND GND GND IN(-)/OUT(-) どちらか
D0 STEP
D1 DIR
D4 どちらか
D8 RST
D9 DS
D10 CS
D11 MOSI
D12 MISO
D13 SCK
D14 SDA
D15 SCL
B2 BLUE
B1 YELLOW
A1 RED
B1 GREEN
VMOT 測定端子(-)
測定端子(+) (+)センター IN(+)

コード

Git Hubでも公開しています。
subcore1用コードでは、maincoreからのパケットを待ってモーターを制御します。

//egg_AI subcore1 用 //ステッピングモーターをコントロールします #if (SUBCORE != 1) #error "Core selection is wrong!!" #endif #include <MP.h> #include <math.h> #define VMAX 100 // モーターの最高速 100で1回転/sec相当(ちがうかも #define CYCLETIME 900000 // Sinカーブに沿って加減速するさいのSin波周期 600000us = 600ms = 0.6sec #define NUMBER_OF_TIME_SEGMENTS 500 // sinカーブをこの数値で分周して疑似サインカーブを生成する #define NUMBER_OF_REPEAT 4 // sin加速を繰り返す回数 const int SW2Pin = 6; const int DirPin = 0; const int StepPin = 1; void setup() { MP.begin(); pinMode(DirPin, OUTPUT); pinMode(StepPin, OUTPUT); digitalWrite(DirPin, LOW); digitalWrite(StepPin, LOW); } void loop() { int ret; int8_t msgid; uint32_t msgdata; ret = MP.Recv(&msgid, &msgdata); delayMicroseconds(1000); stepper_move(); } void stepper_move(){ digitalWrite(DirPin, LOW); MPLog("stepper start\n"); int Vmax = VMAX; int timeCycle = CYCLETIME; //usecの時間で1周期 int numberOfTimeSegments = NUMBER_OF_TIME_SEGMENTS; //1周期を何分周するか int numberOfRepeat = NUMBER_OF_REPEAT; //何周期繰り返すか for (int i = 0; i < numberOfRepeat; i++){ sinAccel(Vmax, numberOfTimeSegments, timeCycle); } } //回転速度Vの時のdelayTimeを返す 1回転/secをV = 100とした場合のtimedelayを計算(まちがってるかも。 int getTimeDelay(int V){ if (V != 0){ return 2500 / abs(V); } else { return -1; } } //stepper motorを1step/timeDelay*2マイクロ秒の速度でステップ void stepMotor(int timeDelay){ digitalWrite(StepPin, HIGH); delayMicroseconds(timeDelay); digitalWrite(StepPin, LOW); delayMicroseconds(timeDelay); } //stepper motorをtimeSegmetマイクロ秒の間、1step/timeDelay*2マイクロ秒の速度でステップ void stepMotorInTimeSegment(int V, int timeSegment){ if (V > 0){ digitalWrite(DirPin, LOW); } else if (V < 0) { digitalWrite(DirPin, HIGH); } if (V != 0){ int timeDelay = getTimeDelay(V); int numberOfSteps = timeSegment / (timeDelay * 2); for (int i = 0; i < numberOfSteps; i++){ stepMotor(timeDelay); } } else{ delayMicroseconds(timeSegment); } } //最高速度Vmaxでsinカーブに沿って加減速する。timeがsinカーブの一周期、一周期をnumberOfTimeSegments回に分周して速度制御する。 void sinAccel(int Vmax, int numberOfTimeSegments, int time){ int timeSegment = time / numberOfTimeSegments; //timeSegmet = 単位時間(us) for (int i = 1 ; i <= numberOfTimeSegments; i++){ float phase = i / (float)numberOfTimeSegments; float rad = phase * 2 * PI; //rad = 現在の位相 int V = sin(rad) * Vmax; //V = 現在の位相での速度 stepMotorInTimeSegment(V, timeSegment); //timeSegment(us)の間、速度Vでステップさせる。 } }

maincore用コードは、学習データ採取用推論用があります。subcoreコードは共通です。
学習データ採取用コードでは、指定した回数の測定を繰り返し、rawデータとFFTデータをmicroSDに保存します。

//たまごAI学習用データ収集用コード //sbucore1には egg_AI_sub.inoを使用 //推論には egg_AI_main.inoを使用 #ifdef SUBCORE #error "Core selection is wrong!!" #endif // データ格納用変数 #define TOTAL_MESUREMENTS 1024 //測定回数 #define MESUREMENT_CYCLE 2500 //測定頻度 us秒/回 #define MESURE_START_DELAY 250000 //モータースタートから測定開始までのディレイタイム usec int freq = 1000000 / MESUREMENT_CYCLE; // スタート一回押した場合の繰り返し測定数 #define MESUREMENT_NUMBER 100 // カットオフ周波数(ハイパス #define CUTOFF 0 #include <Arduino.h> #include <Wire.h> #include <MP.h> //マルチコア制御 int subcore1 = 1; //使用するサブコア #include "LGFX_SPRESENSE_sample.hpp" //ディスプレイ制御 static LGFX lcd; //ディスプレイインスタンス作成 #include <arduinoFFT.h> //FFT用 double vReal[TOTAL_MESUREMENTS]; // vReal[]にサンプリングしたデータを入れる double vImag[TOTAL_MESUREMENTS]; arduinoFFT FFT = arduinoFFT(vReal, vImag, TOTAL_MESUREMENTS, freq); // FFTオブジェクトを作る #include <File.h> //ファイル操作 #include <SDHCI.h> //microSD操作 SDClass SD; File myFile; #include "ammeter.h" //M5Stack 電流センサ Ammeter ammeter; /**Ameter object**/ double am_value[TOTAL_MESUREMENTS]; int elapsed_time[TOTAL_MESUREMENTS]; #include <DNNRT.h> //DNN推論用 DNNRT dnnrt; //ファイル保存用変数 int number = 0; //測定の通し番号 //gpio 定義 const int SW1Pin = 4; int SW1State = LOW; int SW1StateOld = LOW; void setup() { Wire.begin(); //i2cを有効化 Wire.setClock(1000000); //standart mode:100000, fast mode:400000, fast mode plus:1000000, high speed mode:3400000 Serial.begin(115200); //USBシリアル通信を有効化 int ret = 0; ret = MP.begin(subcore1); //subcore1を起動 if (ret < 0) { //subcore起動失敗時の表示 MPLog("MP.begin(%d) error = %d\n", 1, ret); } //ameter 設定 ammeter.setMode(SINGLESHOT); ammeter.setRate(RATE_860); //8,16,32,64,128(defo),250,475,860 ammeter.setGain(PAG_2048); // hope = page512_volt / ammeter.resolution; // | PAG | Max Input Voltage(V) | // | PAG_6144 | 128 | // | PAG_4096 | 64 | // | PAG_2048 | 32 | // | PAG_512 | 16 | // | PAG_256 | 8 | //Switch pin 設定 pinMode(SW1Pin, INPUT_PULLUP); lcd.init(); //ili9431を初期化 lcd.setRotation(1); //右へ90°回転 } void loop() { double am_value_local[TOTAL_MESUREMENTS]; SW1StateOld = SW1State; SW1State = digitalRead(SW1Pin); lcd.setCursor(0,0); //SW1が押されたとき if (SW1State == LOW && SW1StateOld == HIGH){ //測定を指定回数自動で繰り返す。 int mesure_count = MESUREMENT_NUMBER ; //測定回数(回) int mesure_delay = 1500; //測定と測定の間隔(msec) 1500 Serial.println("SW1 pushed"); for (int i = 0; i < mesure_count; i ++){ delay(mesure_delay); lcd.printf("LOW "); Serial.println("mesure current"); mesure_current(); Serial.println(vReal[0]); Serial.println("f to d"); for(int i = 0; i < TOTAL_MESUREMENTS; i++){ am_value[i] = vReal[i]; } Serial.println("DCRemove"); //dummy_data(); DCRemoval(vReal, TOTAL_MESUREMENTS); Serial.println("FFT analyze"); FFT.Windowing(FFT_WIN_TYP_HAMMING, FFT_FORWARD); // 窓関数 FFT.Compute(FFT_FORWARD); // FFT処理(複素数で計算) FFT.ComplexToMagnitude(); // 複素数を実数に変換 Serial.println("normalization"); normalization(vReal, TOTAL_MESUREMENTS); normalization(am_value, TOTAL_MESUREMENTS); Serial.println("draw graph"); //drawGraph(am_value); drawGraph(vReal); Serial.println("saveData"); saveData(); } } else if (SW1State == HIGH && SW1StateOld == LOW) { lcd.printf("HIGH"); } } //電流測定 void mesure_current(){ double current; int now; int old; int elapsedTime; int ret = MP.Send(3,50000,subcore1); delayMicroseconds(MESURE_START_DELAY); //測定開始までのdelay Serial.println("start mesure cycle"); for(int i = 0; i < TOTAL_MESUREMENTS; i++){ // Serial.printf("cycle num = %d / ",i); if (i == 0){ now = micros(); current = ammeter.getCurrent(); } else { old = now; elapsedTime = now - old; while(elapsedTime <= MESUREMENT_CYCLE){ now = micros(); elapsedTime = now - old; } current = ammeter.getCurrent(); old = now; } // Serial.println(elapsedTime); elapsed_time[i] = elapsedTime; vReal[i] = current; vImag[i] = 0; } Serial.println(elapsedTime); } //正規化 void normalization (double *data, int sample_num){ double minValue = data[0]; double maxValue = data[0]; for (int i = 0; i < sample_num; i++) { if (data[i] < minValue) { minValue = data[i]; } if (data[i] > maxValue) { maxValue = data[i]; } } for (int i = 0; i < sample_num; i++) { data[i] = (data[i] - minValue) / (maxValue - minValue); } } // 測定データをmicroSDに保存 void saveData(){ SD.begin(); char fname[16]; sprintf(fname, "fft_%03d.csv", number); myFile = SD.open(fname,FILE_WRITE); if (!myFile){ Serial.println("SD Open Error: "+String(fname)); return; } for (int i = CUTOFF; i < TOTAL_MESUREMENTS / 2 + CUTOFF; i++){ myFile.println(vReal[i]); } myFile.close(); sprintf(fname, "am_%03d.csv", number); myFile = SD.open(fname,FILE_WRITE); if (!myFile){ Serial.println("SD Open Error: "+String(fname)); return; } for (int i = 1; i < TOTAL_MESUREMENTS; i++){ myFile.println(am_value[i]); } myFile.close(); sprintf(fname, "time_%03d.csv", number); myFile = SD.open(fname,FILE_WRITE); if (!myFile){ Serial.println("SD Open Error: "+String(fname)); return; } for (int i = 1; i < TOTAL_MESUREMENTS; i++){ myFile.println(elapsed_time[i]); } myFile.close(); lcd.setCursor(0,220); lcd.printf("number %d saved",number); number++; } //グラフ描画 void drawGraph(double *data){ lcd.fillScreen(lcd.color888(0, 0, 0)); double max = data[0]; double min = data[0]; for (int i = 0; i < TOTAL_MESUREMENTS; i++ ){ if (data[i] > max) max = data[i]; if (data[i] < min) min = data[i]; } int graph_size_x = 320; int graph_size_y = 200; int y0 = graph_size_y - 0; int y1; int x1; for (int i = 0; i < TOTAL_MESUREMENTS / 2; i++ ){ if (i + 1 < graph_size_x){ x1 = i; }else if(i >= graph_size_x){ x1 = i % graph_size_x; } y1 = int(graph_size_y - data[i] / (max - min) * graph_size_y); if(i > 0){ lcd.drawLine(x1 - 1, y0, x1, y1, lcd.color888(0, 255, 0)); } y0 = y1; } } //直流成分除去 void DCRemoval(double *data, int sample_num) { double mean = 0; for (uint16_t i = 1; i < sample_num; i++) { mean += data[i]; } mean /= TOTAL_MESUREMENTS; for (uint16_t i = 1; i < sample_num; i++) { data[i] -= mean; } }

推論用は測定後FFT解析して推論を実行、測定データをmicroSDに保存します。

//たまごAIメインコア用コード //subcore1用コードはegg_AI_sub.ino //microSDカードに学習済みモデルを保存して実行します。 #ifdef SUBCORE #error "Core selection is wrong!!" #endif #include <Arduino.h> #include <Wire.h> #include "arduinoFFT.h" #include <MP.h> int subcore1 = 1; //使用するサブコア #include "LGFX_SPRESENSE_sample.hpp" static LGFX lcd; //ディスプレイインスタンス作成 #include <DNNRT.h> DNNRT dnnrt; #include <SDHCI.h> #include <Flash.h> SDClass SD; /**< SDClass object */ File nnbFile; File myFile; //ファイル保存用変数 int number = 0; //測定の通し番号 // 測定条件 #define TOTAL_MESUREMENTS 1024 //測定回数 #define MESUREMENT_CYCLE 2500 //測定頻度 us秒/回 #define MESURE_START_DELAY 250000 //モータースタートから測定開始までのディレイタイム usec int freq = 1000000 / MESUREMENT_CYCLE; //FFT用 double vReal[TOTAL_MESUREMENTS]; // vReal[]にサンプリングしたデーターを入れる double vImag[TOTAL_MESUREMENTS]; // 虚数部 arduinoFFT FFT = arduinoFFT(vReal, vImag, TOTAL_MESUREMENTS, freq); // FFTオブジェクトを作る #include "ammeter.h" Ammeter ammeter; /**Ameter object**/ double am_value[TOTAL_MESUREMENTS]; int elapsed_time[TOTAL_MESUREMENTS]; //gpio 定義 const int SW1Pin = 4; //switch state管理用 int SW1State = LOW; int SW1StateOld = LOW; void setup() { //nnb file open SD.begin(); nnbFile = SD.open("model.nnb"); //学習済みデータ読み込み if (!nnbFile) { Serial.println("model.nnb is not found"); while(1); } int ret = dnnrt.begin(nnbFile); if (ret <0){ Serial.println("DNNRT begin fail: " + String(ret)); while(1); } Wire.begin(); //i2cを有効化 Wire.setClock(1000000); //standart mode:100000, fast mode:400000, fast mode plus:1000000, high speed mode:3400000 Serial.begin(115200); //USBシリアル通信を有効化 ret = 0; ret = MP.begin(subcore1); //subcore1を起動 if (ret < 0) { //subcore起動失敗時の表示 MPLog("MP.begin(%d) error = %d\n", 1, ret); } //ameter 設定 ammeter.setMode(SINGLESHOT); ammeter.setRate(RATE_860); //8,16,32,64,128(defo),250,475,860 ammeter.setGain(PAG_2048); // hope = page512_volt / ammeter.resolution; // | PAG | Max Input Voltage(V) | // | PAG_6144 | 128 | // | PAG_4096 | 64 | // | PAG_2048 | 32 | // | PAG_512 | 16 | // | PAG_256 | 8 | //Switch pin 設定 pinMode(SW1Pin, INPUT_PULLUP); lcd.init(); //ili9431を初期化 lcd.setRotation(1); //右へ90°回転 lcd.setFont(&fonts::Font0); } void loop() { SW1StateOld = SW1State; SW1State = digitalRead(SW1Pin); lcd.setCursor(0,0); if (SW1State == LOW && SW1StateOld == HIGH){ lcd.printf("Currently measuring \n"); mesure_current(); for(int i = 0; i < TOTAL_MESUREMENTS; i++){ am_value[i] = vReal[i]; } DCRemoval(vReal, TOTAL_MESUREMENTS); //直流成分除去 FFT.Windowing(FFT_WIN_TYP_HAMMING, FFT_FORWARD); // 窓関数 FFT.Compute(FFT_FORWARD); // FFT処理(複素数で計算) FFT.ComplexToMagnitude(); // 複素数を実数に変換 Serial.println("normalization"); normalization(vReal, TOTAL_MESUREMENTS); normalization(am_value, TOTAL_MESUREMENTS); //drawGraph(am_value); drawGraph(vReal); saveData(); //推論実行 DNNVariable input(TOTAL_MESUREMENTS/2); float* data = input.data(); for (int i = 0; i < TOTAL_MESUREMENTS/2; ++i){ data[i] = vReal[i]; } dnnrt.inputVariable(input, 0); dnnrt.forward(); DNNVariable output = dnnrt.outputVariable(0); lcd.setCursor(0,20); lcd.printf("\nMax Index = "); lcd.println(output.maxIndex()); Serial.println(output.maxIndex()); Serial.print("\noutput.maxIndex()="); Serial.println(output[output.maxIndex()]); for (int i = 0; i < 3; i++){ Serial.println(output[i]); lcd.printf("\ni:"); lcd.print(i); lcd.printf(" value:"); lcd.print(output[i]); } lcd.setCursor(0,220); lcd.setFont(&fonts::Font4); if (output.maxIndex() == 0){ lcd.print("This is RAW EGG."); } else if (output.maxIndex() == 1){ lcd.print("This is ONSEN EGG!"); } else if (output.maxIndex() == 2){ lcd.print("This is BOILED EGG."); } lcd.setFont(&fonts::Font0); } else if (SW1State == HIGH && SW1StateOld == LOW) { lcd.printf("set TAMAGO and push start SW"); } } //グラフ描画 void drawGraph(double *data){ lcd.fillScreen(lcd.color888(0, 0, 0)); double max = data[0]; double min = data[0]; for (int i = 0; i < TOTAL_MESUREMENTS; i++ ){ if (data[i] > max) max = data[i]; if (data[i] < min) min = data[i]; } int graph_size_x = 320; int graph_size_y = 200; int y0 = graph_size_y - 0; int y1; int x1; for (int i = 0; i < TOTAL_MESUREMENTS/2; i++ ){ if (i + 1 < graph_size_x){ x1 = i; }else if(i >= graph_size_x){ x1 = i % graph_size_x; } y1 = int(graph_size_y - data[i] / (max - min) * graph_size_y); if(i > 0){ lcd.drawLine(x1 - 1, y0, x1, y1, lcd.color888(0, 255, 0)); } y0 = y1; } } //直流部除去 void DCRemoval(double *vData, int sample_num) { double mean = 0; for (uint16_t i = 1; i < sample_num; i++) { mean += vData[i]; } mean /= sample_num; for (uint16_t i = 1; i < sample_num; i++) { vData[i] -= mean; } } //電流測定 void mesure_current(){ double current; int now; int old; int elapsedTime; int ret = MP.Send(3,50000,subcore1); delayMicroseconds(MESURE_START_DELAY); //測定開始までのdelay Serial.println("start mesure cycle"); for(int i = 0; i < TOTAL_MESUREMENTS; i++){ // Serial.printf("cycle num = %d / ",i); if (i == 0){ now = micros(); current = ammeter.getCurrent(); } else { old = now; elapsedTime = now - old; while(elapsedTime <= MESUREMENT_CYCLE){ now = micros(); elapsedTime = now - old; } current = ammeter.getCurrent(); old = now; } elapsed_time[i] = elapsedTime; vReal[i] = current; vImag[i] = 0; } Serial.println(elapsedTime); } //データを正規化 void normalization (double *data, int sample_num){ double minValue = data[0]; double maxValue = data[0]; for (int i = 0; i < sample_num; i++) { if (data[i] < minValue) { minValue = data[i]; } if (data[i] > maxValue) { maxValue = data[i]; } } for (int i = 0; i < sample_num; i++) { data[i] = (data[i] - minValue) / (maxValue - minValue); } } //電流、FFTCSVファイルで保存 void saveData(){ SD.begin(); char fname[16]; sprintf(fname, "fft_%03d.csv", number); myFile = SD.open(fname,FILE_WRITE); if (!myFile){ Serial.println("SD Open Error: "+String(fname)); return; } for (int i = 0; i < TOTAL_MESUREMENTS / 2; i++){ myFile.println(vReal[i]); } myFile.close(); sprintf(fname, "am_%03d.csv", number); myFile = SD.open(fname,FILE_WRITE); if (!myFile){ Serial.println("SD Open Error: "+String(fname)); return; } for (int i = 1; i < TOTAL_MESUREMENTS; i++){ myFile.println(am_value[i]); } myFile.close(); number++; }

最後に

生たまごとゆでたまごの分類は成功しましたが、エクストラサクセスとした温泉たまごの分類には成功していません。可能であれば温泉たまごの硬さをポイント評価できる温泉たまごスケールの開発まで進めたいと考えています。

データ採取の過程でモーターの回転数を高くした方が内部の情報が多く抽出できている様に見受けられたのですが、温泉卵を高速回転すると半生状態の白身構造が破壊されてしまい生たまごとの差が見えなくなってしまうという現象も発生しました。
データ採取行為自体が測定対象の状態を変化させてしまうというのはありがちなことでもあり、面白く感じました。

世界にただ一つの自分オリジナルAIを作ってみたくて立ち上げたプロジェクトですが、基本機能は満足できました。生たまごとゆでたまごを判別するAIを持っているのは世界でも私ひとりだけかもしれないと思うと愉快な気分です。

おまけ 開発余話

今回の開発では、たまごの内部情報を含んだ学習用データを採取するのにもっとも苦労しました。テスト装置を作成し、データ測定を開始したところ
①再現性のあるデータが得られない
②得られたデータに含まれる特徴が少ない(S/N比が低い)
③テスト測定では良好なデータが得られるが、数百~数千の本測定時に特徴が消える
などの事象に悩まされました。

開発が進むに従い断片的に集まった情報をもとに原因をつぶしていったところ、ここに挙げた問題はすべてたまごホルダーに起因する問題であることが判りました。ホルダーの進化に開発の歴史を見ることが出来ます。
たまごホルダーの進化
上写真は左の第一世代から右端の第五世代までの進化の様子です。
理想的なデータを取得できるまで、仮説を重ねてたまごホルダーの改良を続けました。

第一世代

キャプションを入力できます
たまごの保護を念頭に置き、硬めのゴムフォームにたまごを圧入して固定。
一見多くの特長を抽出できたように見えたが、ゴムフォームが振動を吸収/発振してしまい一体何の情報が見えているのか判らないゴミデータばかり。

第二世代

キャプションを入力できます
たまごをベースの上に直接置き、ソケットをネジ締めして固定。たまごの固定、芯出しを同時に行える基本設計が決定。ホルダーの重量が重すぎるとたまごの特長が埋没する可能性を考慮し、軽量化した第三世代を開発。

第三世代

キャプションを入力できます
ネジピッチを小さくしつつ、ぎりぎりまでソケットの肉厚を削減。たまごホルダーの軽量化/回転トルク低減を狙った。
当初良好かと思われたが繰り返し測定するとS/N比が不足する事例が発生。

第四世代

キャプションを入力できます
S/N比向上をねらい、ベース部品を剛性の許す限り肉抜きして軽量化。
とても良好なS/N比を得られたが、繰り返し測定すると急速にS/N比が悪化。よく観察すると圧入固定していたモーター軸とホルダーの締結部にガタが発生。繰り返し応力により樹脂がへたり、モーターとホルダーの締結が甘くなっていることが確認できた。

第五世代

キャプションを入力できます
モーター軸に対してネジ締めできる構造を追加して、
「モーター」⇔「ホルダー」⇔「たまご」
間の締結をすべて確実に固定することで十分な情報を得られる様になった。

今回の開発を通して、AIというのは何かしら賢いものというイメージから、もう少し具体的にデコーダの一種なのだなと言うイメージを持つことができました。AIの性能を追うのも面白いですが、同時に信号伝達経路の精度を上げることも同じくらい重要であるということを知識としてだけでなく体感できたことも良い経験でした。

airpocketのアイコン画像
電子工作、プログラミング、AI、DIY、XR、IoT M5Stack / Raspberry Pi / Arduino / spresense / K210 / ESP32 / Maix / maicro:bit / oculus / Jetson Nano / minipupper etc
ログインしてコメントを投稿する