junichi.minamino が 2025年01月30日02時12分48秒 に編集
初版
タイトルの変更
カメラの前で音感をチェック(実機の故障?により未完成)
タグの変更
SPRESENSE
マイク
カメラ
ディスプレイ
FFT
メイン画像の変更
記事種類の変更
製作品
ライセンスの変更
(MIT) The MIT License
本文の変更
# 作品の概要 カメラの前で「ドレミファソラシド」のキーの8音を発声して、全て正しい音感が出せればクリア、というゲームの作成を試みました。 ### 目的 Spresenseで複数の負荷のある処理を同時に行い、問題なく動作するか、を確認してみたかったのが目的・動機になります。 - カメラからのビデオデータの動き検出を逐次確認 - マイクからのオーディオデータのピーク周波数を取得 - ディスプレイに、ビデオ画像と、(条件に沿って)図形とテキストを逐次表示 ### ゲームの流れ 1. カメラを顔に合わせて、ゲーム開始のスイッチを押します。ディスプレイに、カメラ画像と、8個の異なる色の正方形が表示されます。 2. カメラに向かって、「ドレミファソラシド」を発声していきます。一致する音程が確認できたら、該当の正方形が非表示になります。 3. ゲームの途中で、カメラからの動きが確認できなかった場合、ゲームはやり直し(すべての正方形が表示)になります。 4. 全てのキーを発声して、全ての正方形が非表示になれば、ゲームクリアとなります。 ### 残念ながら。。。 開発中にデバイスが正常に動作しなくなる事態に。。 全体の処理が大きくなることが予想できたので、負荷が大きそうな箇所の処理をサブコアに移行することを試みました。 マイクからのオーディオデータをサブコアに渡すように実装をしてみて、動作確認をしてみたところ、以下のメモリエラーが発生。 ``` Fail to allocate memory. ERROR: Command(0x11) fails. Result code(0xf1) Module id(0) Error code(0x1) subcode(0x0) ERROR: Command(0x31) fails. Result code(0xf1) Module id(0) Error code(0x1) subcode(0x0) ``` その後、デバイスへのプログラムの書き込みや起動までは成功するものの、最初に少し動作してからは反応せず、という状態になってしまい、正常に戻す方法がわからない(または故障?)ため、これ以降の本格的な開発はできなくなってしまいました。 そのため、作成した下記のプログラムは、動作確認ができていないものとなります。。 # 使用する部品 - Spresense メインボード - Spresense 拡張ボード - Spresense HDRカメラボード - Mic&LCD KIT for Spresense # 設計、実装 ピーク周波数を取得する部分は、[昨年のコンテストでのプログラム](https://elchika.com/article/ab2ddf9f-3335-4e78-a569-d85d62b3376c/)を活用しました。 各音(ドレミファソラシド)の周波数に一致するかの判定は、以下の周波数値に対して、前後3Hzの範囲内ならOKとしました。 |音名|Key|周波数(Hz)|計算| |:---:|:---:|:---:|:---| |ド|C|261.63|220 * 2^(3/12)| ||C#||220 * 2^(4/12)| |レ|D|293.66|220 * 2^(5/12)| ||D#||220 * 2^(6/12)| |ミ|E|329.63|220 * 2^(7/12)| |ファ|F|349.23|220 * 2^(8/12)| ||F#||220 * 2^(9/12)| |ソ|G|392.0|220 * 2^(10/12)| ||G#||220 * 2^(11/12)| |ラ|A|440.0|440 * 2^(0/12)| ||A#||440 * 2^(1/12)| |シ|B|493.88|440 * 2^(2/12)| |ド|C|523.25|440 * 2^(3/12)| また、動きの検出の判定は、取得した画像のピクセルをグレースケール変換したものに対して、直前のデータと比較して行いました。 # 成果物 ### ソースコード 前述の通り、実機での動作確認ができなくなったため、想定の仕様で、コンパイルが成功する状態までの実装を行いました。 ```spresense_camera_mic.ino #include <Camera.h> #include "Adafruit_ILI9341.h" #include <Audio.h> #include <FFT.h> // ディスプレイ #define TFT_DC 9 #define TFT_CS 10 Adafruit_ILI9341 display = Adafruit_ILI9341(TFT_CS, TFT_DC); // スイッチ #define SWITCH_PIN_04 4 #define SWITCH_PIN_05 5 #define SWITCH_PIN_06 6 #define SWITCH_PIN_07 7 int ARRAY_SWITCH_PIN[4] = { SWITCH_PIN_04, SWITCH_PIN_05, SWITCH_PIN_06, SWITCH_PIN_07 }; //// #define FFT_LEN 1024 // モノラル、1024サンプルでFFTを初期化 FFTClass<AS_CHANNEL_MONO, FFT_LEN> FFT; AudioClass *theAudio = AudioClass::getInstance(); //// // ゲームの場面を管理 int m_iScene = 0; // スイッチを押したとき bool m_isChanged = false; // 一致したピッチをマイクから取得 bool m_arIsMatchPitch[8] = {false, false, false, false, false, false, false, false}; //// // カメラ画像の上のレイヤーに正方形を描画 void setSquareBox() { // 正方形のサイズと配置の計算 int rows = 3; // 行数(縦方向) int cols = 4; // 列数(横方向) int squareSizeX = display.width() / cols; // 横方向の正方形のサイズ int squareSizeY = display.height() / rows; // 縦方向の正方形のサイズ // 色の設定 uint16_t colors[] = { 0xF800, // 赤 (Red) 0xFC00, // オレンジ (Orange) 0xFFE0, // 黄色 (Yellow) 0x9E00, // 黄緑 (Yellow-Green) 0x07E0, // 緑 (Green) 0x07FF, // 水色 (Cyan) 0x001F, // 青 (Blue) 0x8010 // 紫 (Purple) }; // 正方形を描画 for (int i = 0; i < rows - 1; i++) { for (int j = 0; j < cols; j++) { int iIndex = i * cols + j; // フラグを確認 if (m_arIsMatchPitch[iIndex] == false) { uint16_t color = colors[iIndex]; // 正方形の位置とサイズを計算 int xPos = j * squareSizeX; int yPos = i * squareSizeY; // 正方形を描画 display.fillRect(xPos, yPos, squareSizeX, squareSizeY, color); } } } } // モニターに描画(カメラ、正方形、テキスト) void CamCB(CamImage img) { if (img.isAvailable()) { img.convertPixFormat(CAM_IMAGE_PIX_FMT_RGB565); uint16_t* imgbuf = (uint16_t*)img.getImgBuff(); // グレースケール変換 uint16_t grayscaleImg[320 * 240]; for (int i = 0; i < 320 * 240; i++) { grayscaleImg[i] = rgb565ToGray(imgbuf[i]) << 8 | rgb565ToGray(imgbuf[i]) << 3 | rgb565ToGray(imgbuf[i]) >> 3; } // 動きの検出 bool isDetectMotion = detectMotion(grayscaleImg); // カメラを描画 display.drawRGBBitmap(0, 0, imgbuf, 320, 240); // 開始座標, 画像データ, 横幅、縦幅 //////// if (m_isChanged == true) { if (m_iScene == 0) { // リセット for (int i = 0; i < 8; i++) { m_arIsMatchPitch[i] = false; } } else if (m_iScene == 1) { // ゲーム開始 for (int i = 0; i < 8; i++) { m_arIsMatchPitch[i] = false; } } m_isChanged = false; } // 正方形を描画 setSquareBox(); //////// // テキストを描画 display.setTextSize(4); // テキストの大きさ display.setCursor(35, 180); if (isDetectMotion) { // 動きが検出された場合の処理 display.setTextColor(ILI9341_RED); display.println("MOVED !"); } else { // 動きがない場合 display.setTextColor(ILI9341_WHITE); display.println("no move"); if (m_iScene == 1) { // ゲーム中、動きがない場合、リセット(すべての正方形を描画) for (int i = 0; i < 8; i++) { m_arIsMatchPitch[i] = false; } } } if (m_iScene == 2) { // ゲームクリア display.setTextColor(ILI9341_RED); display.println("CLEAR !"); } } } void setup() { Serial.begin(115200); // タクトスイッチ Pinモード初期化 for (int i = 0; i < 4; i++) { pinMode(ARRAY_SWITCH_PIN[i], INPUT_PULLUP); } //////// // 液晶ディスプレイの開始 display.begin(); // ディスプレイの向きを設定 display.setRotation(3); // カメラの開始 theCamera.begin(); // カメラのストリーミングを開始 theCamera.startStreaming(true, CamCB); //////// // 入力をマイクに設定 Serial.println("Init Audio Recorder"); theAudio->begin(); theAudio->setRecorderMode(AS_SETRECDR_STS_INPUTDEVICE_MIC); // 録音設定:フォーマットはPCM (16ビットRAWデータ)、 // DSPコーデックの場所の指定 (SDカード上のBINディレクトリ)、 // サンプリグレート 48000Hz、モノラル入力 int err = theAudio->initRecorder(AS_CODECTYPE_PCM, "/mnt/sd0/BIN", AS_SAMPLINGRATE_48000 ,AS_CHANNEL_MONO); if (err != AUDIOLIB_ECODE_OK) { Serial.println("Recorder initialize error"); while(1); } // 録音開始 Serial.println("Start Recorder"); theAudio->startRecorder(); } void loop() { float maxValue0 = 0.0; float peakFs0 = 0.0; // FFTの結果からピーク値を取得 procCalcPeakFsWithFFT(&maxValue0, &peakFs0); // 各音(ドレミファソラシド)の周波数に一致するか? checkPeakFs0(peakFs0); bool isAllMatchPitch = true; if (m_iScene == 1) { for (int i = 0; i < 8; i++) { if (m_arIsMatchPitch[i] == false) { isAllMatchPitch = false; break; } } if (isAllMatchPitch == true) { // ゲームクリア m_iScene = 2; } } //////// // スイッチ for (int i = 0; i < 4; i++) { int switchState = digitalRead(ARRAY_SWITCH_PIN[i]); if (switchState == LOW) { Serial.print("Switch ON"); Serial.println(4 + i); // 押したとき if (i == 0 || i == 1) { m_iScene = i; m_isChanged = true; } } else { Serial.print("Switch off"); Serial.println(4 + i); } } delay(100); } // FFTの結果からピーク値を取得 void procCalcPeakFsWithFFT(float *peakFs0, float *maxValue0) { static const uint32_t buffering_time = FFT_LEN * 1000 / AS_SAMPLINGRATE_48000; static const uint32_t buffer_size = FFT_LEN * sizeof(int16_t); static char buff[buffer_size]; static float pDst[FFT_LEN]; uint32_t read_size; // マイクから読み込み int ret = theAudio->readFrames(buff, buffer_size, &read_size); if (ret != AUDIOLIB_ECODE_OK && ret != AUDIOLIB_ECODE_INSUFFICIENT_BUFFER_AREA) { Serial.println("Error err = " + String(ret)); theAudio->stopRecorder(); exit(1); } // FFTを実行 FFT.put((q15_t*)buff, FFT_LEN); // FFT演算結果を取得 FFT.get(pDst, 0); // 過去のFFT演算結果で平滑化 avgFilter(pDst); // 周波数スペクトルの最大値最小値を検出 float fpmax = FLT_MIN; float fpmin = FLT_MAX; for (int i = 0; i < FFT_LEN/8; ++i) { if (pDst[i] < 0.0) pDst[i] = 0.0; if (pDst[i] > fpmax) fpmax = pDst[i]; if (pDst[i] < fpmin) fpmin = pDst[i]; } //////////////////////////////// // 周波数のピーク値を上位4つまで取得 float maxValue[4] = {0.0, 0.0, 0.0, 0.0}; float peakFs[4] = {0.0, 0.0, 0.0, 0.0}; int ret_peak = get_peak_frequency_list(pDst, peakFs, maxValue); // Subcoreに渡すデータ float dataForSubcore[8]; for (int i = 0; i < 4; i++) { dataForSubcore[i * 2] = peakFs[i]; dataForSubcore[i * 2 + 1] = maxValue[i]; } //////////////////////////////// *peakFs0 = peakFs[0]; *maxValue0 = maxValue[0]; } // 各音(ドレミファソラシド)の周波数に一致するか? void checkPeakFs0(float peakFs0) { float arFreq[] = { 261.63, 293.66, 329.63, 349.23, 392.0, 440.0, 493.88, 523.25 }; for (int i = 0; i < 8; i++) { // 前後3Hzの範囲内ならOK if (peakFs0 >= arFreq[i] - 3.0 && peakFs0 <= arFreq[i] + 3.0) { m_arIsMatchPitch[i] = true; break; } } } ``` ```Util.ino //////////////// // カメラ //////////////// uint16_t prevImg[320 * 240]; // 前回の画像のデータを保持するための配列 // グレースケール変換関数 uint8_t rgb565ToGray(uint16_t color) { uint8_t r = (color >> 11) & 0x1F; // R (5ビット) uint8_t g = (color >> 5) & 0x3F; // G (6ビット) uint8_t b = color & 0x1F; // B (5ビット) // R、G、Bの各成分を0-255の範囲にスケール r = r * 255 / 31; g = g * 255 / 63; b = b * 255 / 31; // 輝度(Y)を計算(加重平均) return (uint8_t)(0.299 * r + 0.587 * g + 0.114 * b); } // 動きの検出を行う関数 bool detectMotion(uint16_t* currentImg) { int diffCount = 0; const int threshold = 30; // 動きの閾値(変更しやすい) // 画像の各ピクセルを比較して差分を計算 for (int i = 0; i < 320 * 240; i++) { uint8_t prevGray = rgb565ToGray(prevImg[i]); uint8_t currGray = rgb565ToGray(currentImg[i]); int diff = abs(prevGray - currGray); // 差分が閾値を超えた場合、動きあり if (diff > threshold) { diffCount++; } } // 動きがあったとき return diffCount > (320 * 240) / 10; // 全体の10%以上の差分がある場合に動きありと判定 } //////////////// // マイク //////////////// // フィルター処理 void avgFilter(float dst[FFT_LEN]) { static const int avg_filter_num = 8; static float pAvg[avg_filter_num][FFT_LEN/8]; static int g_counter = 0; if (g_counter == avg_filter_num) g_counter = 0; for (int i = 0; i < FFT_LEN/8; ++i) { pAvg[g_counter][i] = dst[i]; float sum = 0; for (int j = 0; j < avg_filter_num; ++j) { sum += pAvg[j][i]; } dst[i] = sum / avg_filter_num; } ++g_counter; } // 周波数のピーク値を上位4つまで取得 int get_peak_frequency_list(float *pData, float *peakFs, float *maxValue) { uint32_t idx; float delta, delta_spr; // 周波数分解能(delta)を算出 const float delta_f = AS_SAMPLINGRATE_48000 / FFT_LEN; const int Max_Key_Num = 4; for (int i = 0; i < Max_Key_Num; i++) { if (i > 0) { pData[idx-1] = 0.0; pData[idx] = 0.0; pData[idx+1] = 0.0; } // 最大値と最大値のインデックスを取得 float maxValueTmp = 0.0; arm_max_f32(pData, FFT_LEN/2, &maxValueTmp, &idx); if (idx < 1) return i; float data_sub = pData[idx-1] - pData[idx+1]; float data_add = pData[idx-1] + pData[idx+1]; // 周波数のピークの近似値を算出 delta = 0.5 * data_sub / (data_add - (2.0 * pData[idx])); peakFs[i] = (idx + delta) * delta_f; // スペクトルの最大値の近似値を算出 delta_spr = 0.125 * data_sub * data_sub / (2.0 * pData[idx] - data_add); maxValue[i] = maxValueTmp + delta_spr; } return Max_Key_Num; } ``` # 考察 本来は、複数の負荷のある処理を同時に行う事象を対象に、メインコアだけで動作させた場合と、サブコアに処理を分散させて動作させた場合とで、挙動の比較をすることで、何らか考察できると思っていました。 しかし、前述の通り、開発中にデバイスが正常に動作しなくなる事態になり、行いたかった考察には至らない結果となりました。 (正常に動作しなくなるきっかけとしては、FFT処理そのものをサブコアに移行して、メインコアからはマイクからのオーディオデータを渡すような仕組みに書き換えて、実機で動作させたときでした。) 今回は(デバイスを正常にするまでの)時間が足りなかったため、今後の検討項目としては、まず現状のデバイスの状態を調査していきます。もし正常に戻すことができれば、上に書いたように、サブコアに処理を分散させる処理を取り組みたいと考えています。 # 参考資料 SPRESENSEではじめるローパワーエッジAI