🕊️

カラスの鳴き声で画像記録!?ESP-EYEで始める音声解析と画像保存表示システムの構築🎤📷

2024/08/31に公開

はじめに

本記事では、ESP-EYEを用いてカラスの鳴き声をトリガーに画像を保存するシステムの構築方法を紹介します。
このシステムは、ESP32のマイクロコントローラとサーバーを使用して音声解析を行い、カラスの鳴き声を検出すると画像をキャプチャして保存します。保存した画像はWeb画面により表示します。

以下に検出して、表示したものの例を示します。

ESPEYEとは?

ESP-EYEは、およそ3000円という安価に購入できる、2メガピクセルのカメラとマイク内蔵のESP32開発ボードです。PCとボードがあれば、ArduinoIDE環境(C++風言語)または、ESPIDF環境(C言語)により気軽に開発を始めることが可能です。

システムの構成図

使用するモノ

使用するモノ 説明など
レンタルサーバ 音声解析と画像保存を行うサーバ。OSはUbuntuを使用し、Flaskが動作する環境が必要です。
ESP-EYE マイクとカメラを搭載したマイクロコントローラ。音声検出と画像キャプチャに使用。
パソコン ESP-EYEのファームウェアを開発するためのPC。今回はWindows 11上でArduino IDEを使用。
カラスのぬいぐるみ 押すと鳴くカラスのぬいぐるみ。デバッグ用に使用します。

音声解析に使う手法:短フーリエ変換(STFT)

短フーリエ変換を使います。そもそもフーリエ変換とはどういうものなのでしょうか?
たくさんの種類の果物が混ざったフルーツミックスジュースをイメージするとわかりやすく考えることができます。
①複雑な音:たくさんの種類の果物が混ざったフルーツミックス
②サイン波:1種類の果物(例えば、バナナ)
③フーリエ変換:フルーツミックスを、それぞれの果物に仕分ける作業

上記のことからわかるように、フーリエ変換とは、複雑な音をバラバラのシンプルな音に分解する魔法なのです!(この記事では数式は省略します)

その中で、短フーリエ変換(STFT)は、信号を短い区間に分割し、それぞれの区間に対してフーリエ変換を行うことで、ある瞬間の周波数成分の情報を得ることができる方法です。

音声信号のSTFTは、時間経過とともにどのように音の周波数成分が変化するかを可視化できます。これにより、音声の抑揚や変化を詳細に分析できます。
この手法を使って、カラスの鳴き声とそうでない音を分類したいと思います。

大まかな仕様:二つのwavファイルを比較しSTFTの類似度の計算して閾値超えなら撮影する

ESP-EYEで音声をキャプチャし、サーバ側でSTFTを用いて周波数成分を解析します。カラスの鳴き声を検出すると、ESP32に信号を送り、カメラで画像をキャプチャしてFlaskサーバーに送信します。送信された画像はサーバー上に保存され、Webブラウザで確認可能です。
★Flaskアプリケーションの仕様
Flaskを使って構築されたウェブサーバーで、画像と音声データのアップロードおよび処理を行います。
●画像表示画面 (/ エンドポイント)
 固定の画像ファイル(take.jpg)をウェブページに表示します。
●画像ファイルアクセス (/imgfile/<filename> エンドポイント)
 upload_img_dirディレクトリから指定された画像ファイルを表示します。
●音声データ処理 (/upload エンドポイント):
 ・音声データをPOSTリクエストで受信し、指定されたファイルパスに保存します。
 ・保存した音声ファイルを事前に用意された「アラーム音」と比較し、
  類似度を計算します。
 ・類似度が閾値 (stft_th) を超えると、「birdvoice」と応答し、
  それ以外の場合は「nobirdvoice」と応答します。
 ・処理後、保存した音声ファイルを削除します。
●画像アップロード (/imgendp エンドポイント)
 ・JSON形式でBase64エンコードされた画像データをPOSTリクエストで受信します。
 ・受信したデータをデコードし、指定されたファイルパスに保存します。
 ・保存が成功すると成功メッセージを返し、エラーが発生した場合は
  エラーメッセージを返します。
●セキュリティ
  SSL/TLS: HTTPSでの通信を確保するためにSSL/TLS証明書を使用します。

この仕様により、アプリケーションは画像の表示、音声データの処理と比較、Base64エンコードされた画像のアップロード、画像の確認などが可能です。

構築方法(ソースコード例など)

①ESP-EYEの音声、画像データを受け付けて解析するサーバ実装

※python製WEBフレームワークFlaskを利用
※以下コマンドでライブラリをインストール
不足のものがあれば適宜インストールください。

sudo pip3 install numpy scipy librosa flask

※librosaが内部処理(音声デコード)で使用

sudo apt-get install ffmpeg

※比較する対象のファイル「bird.wav」をあらかじめESP-EYEのマイク越しに収録して
「uploads」フォルダに配置してください。※SSLサーバ証明書設定等のサーバ設定は省略します。

flaskソースコード
app.py
from flask import Flask, request, jsonify, render_template_string, send_from_directory
import os
import ssl
import numpy as np
import librosa
import base64
from scipy.spatial.distance import cosine

# Flaskアプリケーションの設定
app = Flask(__name__)
app.config['SECRET_KEY'] = 'secret!'

# SSLコンテキストの設定
def create_ssl_context(cert_path, key_path):
    context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2)
    context.load_cert_chain(cert_path, key_path)
    return context
#SSLサーバ証明書の適用(例:Let's Encrypt)
ssl_context = create_ssl_context(
    '/etc/letsencrypt/live/***適宜差し替える***/fullchain.pem',
    '/etc/letsencrypt/live/***適宜差し替える***/privkey.pem'
)

# 設定値
STFT_TH = 70.0
UPLOAD_IMG_DIR = "/home/***適宜差し替える***/uploads/"
ALARM_FILE_PATH = os.path.join(UPLOAD_IMG_DIR, 'bird.wav')
UPLOAD_FILE_PATH = os.path.join(UPLOAD_IMG_DIR, 'target.wav')

# オーディオファイルを読み込む関数
def load_audio(file_path, sr=16000):
    try:
        y, sr = librosa.load(file_path, sr=sr)
    except Exception as e:
        print(f"librosaでオーディオファイルの読み込みエラー: {e}")
        return None, None
    return y, sr

# STFTの類似度を計算する関数
def compute_stft_similarity(y1, y2):
    D1 = np.abs(librosa.stft(y1))
    D2 = np.abs(librosa.stft(y2))
    min_length = min(D1.shape[1], D2.shape[1])
    D1 = D1[:, :min_length]
    D2 = D2[:, :min_length]
    return 1 - cosine(D1.flatten(), D2.flatten())

# アラーム音とアップロードされた音声を比較する関数
def compare_alarms(alarm_file, upload_file):
    alarm_y, _ = load_audio(alarm_file)
    target_y, _ = load_audio(upload_file)
    if alarm_y is None or target_y is None:
        return None
    return compute_stft_similarity(alarm_y, target_y)

# アップロードされた画像を表示するルート
@app.route('/')
def index():
    image_file = 'take.jpg'
    html_template = '''
        <h1>アップロードされた画像</h1>
        <div style="margin: 10px; display: inline-block;">
            <img src="{{ url_for('uploaded_file', filename=image) }}" alt="{{ image }}" width="300">
        </div>
    '''
    return render_template_string(html_template, image=image_file)

# 画像ファイルを提供するルート
@app.route('/imgfile/<filename>')
def uploaded_file(filename):
    return send_from_directory(UPLOAD_IMG_DIR, filename)

# 音声データを受信して処理するルート
@app.route('/upload', methods=['POST'])
def upload_file():
    input_data = request.data
    if input_data:
        try:
            with open(UPLOAD_FILE_PATH, 'wb') as f:
                f.write(input_data)
            
            stft_similarity = compare_alarms(ALARM_FILE_PATH, UPLOAD_FILE_PATH)
            if stft_similarity is not None and float(stft_similarity) * 100 > STFT_TH:
                os.remove(UPLOAD_FILE_PATH)
                return "birdvoice"
            else:
                os.remove(UPLOAD_FILE_PATH)
                return "nobirdvoice"
        except IOError:
            return jsonify({"message": "ファイルの保存に失敗しました"}), 500
    else:
        return jsonify({"message": "データが受信されていません"}), 400

# 画像を受信して保存するルート
@app.route('/imgendp', methods=['POST'])
def upload_image():
    try:
        data = request.get_json()
        encoded_data = data['img']
        image_data = base64.b64decode(encoded_data)
        file_path = os.path.join(UPLOAD_IMG_DIR, "take.jpg")
        with open(file_path, 'wb') as file:
            file.write(image_data)
        return jsonify({'message': '画像のアップロードに成功しました!'}), 200
    except Exception as e:
        return jsonify({'error': str(e)}), 500

# アプリケーションを起動
if __name__ == '__main__':
    #仮にビルドインサーバで動作させている
   #実運用では、WSGI(Web Server Gateway Interface)と
    # WEBサーバ(nginxなど)とのつなぎ込みが別途必要
    app.run(host='0.0.0.0', port=443, ssl_context=ssl_context, debug=True)

②ESP-EYE側の音声送信と画像送信

※書き込み時の注意点

ESP-EYEのマイク音声を送信し、カラスの鳴き声とサーバが判断したら、返却値にカラス(bird)ステータスをを返し、それを受け取ったらサーバに現在の画像を送信します。
※初期値として5秒程度音声収録して1秒待機しての繰り返し処理になっています。
※flask側では閾値でbird(カラス)と判定する閾値は70.0より大きい場合としています。

ESP32ソースコード
voicecam_firm.ino
#include <WiFi.h>
#include "soc/soc.h"
#include "soc/rtc_cntl_reg.h"
#include "driver/i2s.h"
#include <SPIFFS.h>
#include <HTTPClient.h>
#include <Arduino.h>
#include "esp_camera.h"
#include <base64.h>


// WiFi設定
const char* ssid = "SSID";
const char* password = "PASS";
const char* imgendp = "https://<ドメイン>/imgendp";
const char* voiceendp = "https://<ドメイン>/upload";
// I2S設定
#define I2S_NUM             I2S_NUM_1
#define I2S_SAMPLE_RATE     16000
#define I2S_BUFFER_SIZE     512
#define I2S_PIN_CLK         26
#define I2S_PIN_WS          32
#define I2S_PIN_DOUT        I2S_PIN_NO_CHANGE
#define I2S_PIN_DIN         33
#define FILE_PATH           "/record.wav"
#define TEMP_FILE_PATH      "/temp.wav"
#define RECORD_DURATION_MS  5000  // 録音時間(ミリ秒)


// カメラ設定
#define PWDN_GPIO_NUM    -1
#define RESET_GPIO_NUM   -1
#define XCLK_GPIO_NUM    4
#define SIOD_GPIO_NUM    18
#define SIOC_GPIO_NUM    23
#define Y9_GPIO_NUM      36
#define Y8_GPIO_NUM      37
#define Y7_GPIO_NUM      38
#define Y6_GPIO_NUM      39
#define Y5_GPIO_NUM      35
#define Y4_GPIO_NUM      14
#define Y3_GPIO_NUM      13
#define Y2_GPIO_NUM      34
#define VSYNC_GPIO_NUM   5
#define HREF_GPIO_NUM    27
#define PCLK_GPIO_NUM    25
// I2S マイクの初期化
void i2sMicInit() {
  i2s_config_t i2s_config = {
    .mode                 = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_RX),
    .sample_rate          = I2S_SAMPLE_RATE,
    .bits_per_sample      = I2S_BITS_PER_SAMPLE_16BIT,
    .channel_format       = I2S_CHANNEL_FMT_ONLY_LEFT,
    .communication_format = I2S_COMM_FORMAT_I2S,
    .intr_alloc_flags     = ESP_INTR_FLAG_LEVEL1,
    .dma_buf_count        = 4,
    .dma_buf_len          = I2S_BUFFER_SIZE / 4,  // 256 samples
    .use_apll             = false,
    .tx_desc_auto_clear   = false,
    .fixed_mclk           = 0
  };
  i2s_pin_config_t pin_config = {
    .bck_io_num           = I2S_PIN_CLK,
    .ws_io_num            = I2S_PIN_WS,
    .data_out_num         = I2S_PIN_DOUT,
    .data_in_num          = I2S_PIN_DIN,
  };
  i2s_driver_install(I2S_NUM, &i2s_config, 0, NULL);
  i2s_set_pin(I2S_NUM, &pin_config);
}
void CamInit() {
  WRITE_PERI_REG(RTC_CNTL_BROWN_OUT_REG, 0);
  camera_config_t config;
  config.ledc_channel = LEDC_CHANNEL_0;
 config.ledc_timer = LEDC_TIMER_0;
  config.pin_d0 = Y2_GPIO_NUM;
  config.pin_d1 = Y3_GPIO_NUM;
  config.pin_d2 = Y4_GPIO_NUM;
  config.pin_d3 = Y5_GPIO_NUM;
  config.pin_d4 = Y6_GPIO_NUM;
  config.pin_d5 = Y7_GPIO_NUM;
  config.pin_d6 = Y8_GPIO_NUM;
  config.pin_d7 = Y9_GPIO_NUM;
  config.pin_xclk = XCLK_GPIO_NUM;
  config.pin_pclk = PCLK_GPIO_NUM;
  config.pin_vsync = VSYNC_GPIO_NUM;
  config.pin_href = HREF_GPIO_NUM;
  config.pin_sscb_sda = SIOD_GPIO_NUM;
  config.pin_sscb_scl = SIOC_GPIO_NUM;
  config.pin_pwdn = PWDN_GPIO_NUM;
  config.pin_reset = RESET_GPIO_NUM;
  config.xclk_freq_hz = 20000000;
  config.pixel_format = PIXFORMAT_JPEG;
  if(psramFound()){
    config.frame_size = FRAMESIZE_P_FHD;
    config.jpeg_quality = 9;  //0-63 lower number means higher quality
    config.fb_count = 2;
  } else {
    config.frame_size = FRAMESIZE_P_FHD ; 
    config.jpeg_quality = 12;  //0-63 lower number means higher quality
    config.fb_count = 1;
  }
  esp_err_t err = esp_camera_init(&config);
  if (err != ESP_OK) {
    Serial.printf("Camera init failed with error 0x%x", err);
  }
}
String takePhoto() {
  camera_fb_t * fb = esp_camera_fb_get();
  if (!fb) {
    Serial.println("Camera capture failed");
    return "";
  }
  String base64Str = base64::encode(fb->buf, fb->len);
  esp_camera_fb_return(fb);
  return base64Str;
}
void svrbase64post(String encodedData){
  // HTTPClientオブジェクトの作成
  HTTPClient http;
  // HTTP POSTリクエストの準備
  http.begin(imgendp);
  http.addHeader("Content-Type", "application/json");
  // POSTリクエストのボディ
  String payload = "{\"img\":\"" + encodedData + "\"}";
  // HTTP POSTリクエストを送信
  int httpResponseCode = http.POST(payload);
  // レスポンスを確認
  if (httpResponseCode > 0) {
    String response = http.getString();
    Serial.println("HTTP Response code: " + String(httpResponseCode));
    Serial.println("Response: " + response);
  } else {
    Serial.println("Error code: " + String(httpResponseCode));
  }


  // HTTPClientオブジェクトの終了
  http.end();
}
// WAV ヘッダーを作成
void createWavHeader(uint8_t* header, int dataSize, int sampleRate, int channels, int bitsPerSample) {
  // WAVヘッダーの作成
  header[0]  = 'R';
  header[1]  = 'I';
  header[2]  = 'F';
  header[3]  = 'F';
  int32_t chunkSize = dataSize + 36;
  header[4]  = (chunkSize & 0xFF);
  header[5]  = (chunkSize >> 8) & 0xFF;
  header[6]  = (chunkSize >> 16) & 0xFF;
  header[7]  = (chunkSize >> 24) & 0xFF;
  header[8]  = 'W';
  header[9]  = 'A';
  header[10] = 'V';
  header[11] = 'E';
  header[12] = 'f';
  header[13] = 'm';
  header[14] = 't';
  header[15] = ' ';
  header[16] = 16;
  header[17] = 0;
  header[18] = 0;
  header[19] = 0;
  header[20] = 1;
  header[21] = 0;
  header[22] = channels;
  header[23] = 0;
  header[24] = (sampleRate & 0xFF);
  header[25] = (sampleRate >> 8) & 0xFF;
  header[26] = (sampleRate >> 16) & 0xFF;
  header[27] = (sampleRate >> 24) & 0xFF;
  int32_t byteRate = sampleRate * channels * bitsPerSample / 8;
  header[28] = (byteRate & 0xFF);
  header[29] = (byteRate >> 8) & 0xFF;
  header[30] = (byteRate >> 16) & 0xFF;
  header[31] = (byteRate >> 24) & 0xFF;
  int16_t blockAlign = channels * bitsPerSample / 8;
  header[32] = blockAlign & 0xFF;
  header[33] = (blockAlign >> 8) & 0xFF;
  header[34] = bitsPerSample;
  header[35] = 0;
  header[36] = 'd';
  header[37] = 'a';
  header[38] = 't';
  header[39] = 'a';
  header[40] = (dataSize & 0xFF);
  header[41] = (dataSize >> 8) & 0xFF;
  header[42] = (dataSize >> 16) & 0xFF;
  header[43] = (dataSize >> 24) & 0xFF;
}
void voicerec() {
  File file= SPIFFS.open(FILE_PATH, FILE_WRITE);
  if (!file) {
    Serial.println("Failed to open file for writing");
    return;
  }
  uint8_t buffer[I2S_BUFFER_SIZE];
  size_t totalBytesRead = 0;
  unsigned long startTime = millis();
  Serial.print("Recording start");
  while (millis() - startTime < RECORD_DURATION_MS) {
    size_t bytesRead;
    i2s_read(I2S_NUM, buffer, sizeof(buffer), &bytesRead, portMAX_DELAY);
    file.write(buffer, bytesRead);
    totalBytesRead += bytesRead;
  }


  file.close();
  Serial.println("Data written to SPIFFS");


  uint8_t wavHeader[44];
  createWavHeader(wavHeader, totalBytesRead, I2S_SAMPLE_RATE, 1, 16);


  File tempFile = SPIFFS.open(TEMP_FILE_PATH, FILE_WRITE);
  if (!tempFile) {
    Serial.println("Failed to open temporary file");
    return;
  }


  File originalFile = SPIFFS.open(FILE_PATH, FILE_READ);
  if (!originalFile) {
    Serial.println("Failed to open file for reading");
    tempFile.close();
    return;
  }


  tempFile.write(wavHeader, sizeof(wavHeader));
  uint8_t readBuffer[1024];
  size_t bytesRead;
  while ((bytesRead = originalFile.read(readBuffer, sizeof(readBuffer))) > 0) {
    tempFile.write(readBuffer, bytesRead);
  }
  originalFile.close();
  tempFile.close();
  SPIFFS.remove(FILE_PATH);
  SPIFFS.rename(TEMP_FILE_PATH, FILE_PATH);
  // HTTPクライアントの初期化
  HTTPClient http;
  http.begin(voiceendp);
  http.addHeader("Content-Type", "audio/wav");
  // WAVファイルを開く
  File wavFile = SPIFFS.open(FILE_PATH, "r");
  if (!wavFile) {
    Serial.println("Failed to open WAV file");
    return;
  }
  // HTTP POSTでファイルを送信
  int httpResponseCode = http.sendRequest("POST", &wavFile, wavFile.size());
  if (httpResponseCode > 0) {
    String response = http.getString();
    if(response == "birdvoice") {
      Serial.println("takepic");
      svrbase64post(takePhoto());
    } else {
      Serial.println("nobird");
    }
  } else {
    Serial.println("Error on sending POST: " + String(httpResponseCode));
  }


  wavFile.close();
  http.end();
}
void setup() {
  Serial.begin(115200);
  delay(100);
  CamInit();
  i2sMicInit();
  if (!SPIFFS.begin(true)) {
    Serial.println("Failed to mount file system");
    return;
  }
  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) {
    delay(1000);
    Serial.print(".");
  }
  Serial.println("Connected to WiFi");
}
void loop() {
  voicerec();
  delay(1000);  // 1秒の待機時間を追加して、ループの実行間隔を調整
}

全体の所感・まとめ

ESP-EYEの音質があんまりよくなかったり、カラス音検出精度がイマイチな部分もあったので、
アルゴリズム等の改善や他の手法も検討できると思います。あくまでたたき台的なシステムサンプル的な位置づけてみていただけますと幸いです。

今回紹介したシステムにより、特定の音をトリガーにして画像をキャプチャし、保存する自律型監視システムを構築することができます。この技術は、自然観察や機械故障音分析など、さまざまな応用が期待されます。
これをベースに、例えば、LINE、slackやメール等に通知するように改良したりしてシステムをさらに発展させ、より高度な音声解析や複数のデバイス間でのデータ連携など検討してみてください。

ご意見や、ご感想お待ちしております😂

Discussion