亲,“电路城”已合并升级到更全、更大、更强的「新与非网」。点击查看「新与非网」

本网页已闲置超过3分钟,按键盘任意键或点击空白处,即可回到网页

基于Arduino Nano 的打鼾卫士

发布时间:2021-08-22
分享到:

基于Arduino Nano 的打鼾卫士

发布时间:2021-08-22
分享到:

据估计,打鼾影响了美国 57% 的男性和 40% 的女性。它甚至发生在高达 27% 的儿童身上。这些统计数据表明打鼾很普遍,但其严重程度和健康影响可能有所不同。打鼾可能是轻微的、偶尔的和无关紧要的,也可能是严重的潜在睡眠相关呼吸障碍的征兆。打鼾是由喉咙后部气道附近组织的嘎嘎声和振动引起的。在睡眠期间,肌肉松弛,使气道变窄,当我们吸气和呼气时,流动的空气会导致组织颤动并发出噪音。阻塞性睡眠呼吸暂停是一种呼吸障碍,在睡眠期间气道阻塞或塌陷,导致反复呼吸困难。打鼾是阻塞性睡眠呼吸暂停最常见的症状之一。除非有人告诉他们,否则大多数打鼾的人都没有意识到这一点,这也是睡眠呼吸暂停未被诊断出的部分原因。

在这个项目中,我将建立一个非侵入性低功率边缘设备的概念证明,它可以在你睡眠期间监测你的打鼾情况,并发出振动。   

开发环境
我们使用 Edge Impulse Studio 进行特征生成以及 TensorFlow Lite 模型的创建和训练。我们需要在https://studio.edgeimpulse.com注册一个免费帐户并创建一个项目才能开始。MacOS 用于本地开发工作。

数据采集
我们使用了Audioset,一个手动注释的音频事件的大规模数据集,来下载夜间可能发生的打鼾和其他自然声音。AudioSet 由 632 个音频事件类的扩展本体和从 YouTube 视频中提取的人类标记的 10 秒声音剪辑的集合组成。音频从选定事件的 YouTube 视频中提取,并转换为波形音频文件格式 (wav),具有 16 位深度的单声道,采样率为 16KHz。下载了从Audioset Ontology中选择的以下类别。第一列是类别 ID,第二列是类别标签。

/m/01d3sd  Snoring 
/m/07yv9   Vehicle
/m/01jt3m  Toilet flush
/m/06mb1   Rain
/m/03m9d0z Wind
/m/07c52   Television
/m/06bz3   Radio
/m/028v0c  Silence
/m/03vt0   Insect
/m/07qjznl Tick-tock
/m/0bt9lr  Dog
/m/01hsr_  Sneeze
/m/01b_21  Cough
/m/07ppn3j Sniff
/m/07pbtc8 Walk, footsteps
/m/02fxyj  Humming
/m/07q6cd_ Squeak
/m/0btp2   Traffic noise, roadway noise
/m/09l8g   Human Voice
/m/07pggtn Chirp, tweet
/t/dd00002 Baby cry, infant cry
/m/04rlf   Music

   

数据集分为两类,打鼾和噪声。通过过滤平衡训练、不平衡训练和评估数据集 CSV 文件创建了两个 CSV 文件 snoring.csv 和 noise.csv,其中包含 YouTube 剪辑 URL 和其他元数据,可以从这里下载。

下面的 bash 脚本 (download.sh) 用于下载视频剪辑并将音频提取为 wav 文件。请在运行以下命令之前安装youtube-dlffmpeg

#!/bin/bash

SAMPLE_RATE=16000
# fetch_youtube_clip(videoID, startTime, endTime)
fetch_youtube_clip() {
  echo "Fetching $1 ($2 to $3)..."
  outname="$1_$2"
  if [ -f "${outname}.wav" ]; then
    echo "File already exists."
  return
fi
  youtube-dl https://youtube.com/watch?v=$1 \
  --quiet --extract-audio --audio-format wav \
  --output "$outname.%(ext)s"
  if [ $? -eq 0 ]; then
    yes | ffmpeg -loglevel quiet -i "./$outname.wav" -ar $SAMPLE_RATE \
    -ac 1 -ss "$2" -to "$3" "./${outname}_out.wav"
    mv "./${outname}_out.wav" "./$outname.wav"
  else
    sleep 1
  fi
}

grep -E '^[^#]' | while read line
do
  fetch_youtube_clip $(echo "$line" | sed -E 's/, / /g')
done

要执行脚本,请运行以下命令。

$ cat noise.csv | ./download.sh
$ cat snoring.csv | ./download.sh

使用 Edge Impulse Uploader 将数据集上传到 Edge Impulse Studio。请按照此处的说明安装 Edge Impulse CLI 工具并执行以下命令。

$ edge-impulse-uploader --category split --label snoring  snoring/*.wav
$ edge-impulse-uploader --category split --label noise  noise/*.wav

上面的命令还将数据集拆分为训练和测试样本。我们可以在 Edge Impulse Studio 的数据采集页面中看到上传的数据集。

所述打鼾事件的音频剪辑有背景噪声在其中从通过分裂段夹子取出多个打鼾事件之间。该噪声类音频剪辑使用,无需任何修改。

我们可以通过选择每个样本并从下拉菜单中单击“拆分样本”来进行拆分,但这是一项耗时且乏味的工作。幸运的是,有一个 Edge Impulse SDK API 可用于自动化该过程。

import json
import requests
import logging
import threading

API_KEY = "<Insert Edge Impulse API Key here from the Dashboard > Keys"
projectId = "<Your project ID, can be found at Edge Impulse dashboard"
headers = {
  "Accept": "application/json",
  "x-api-key": API_KEY
}
def segment(tid, ids):
    for sampleId in ids:
    url1 = "https://studio.edgeimpulse.com/v1/api/{}/raw-data/{}/find-segments".format(projectId, sampleId)
    payload1 = {
        "shiftSegments": True,
        "segmentLengthMs": 1500
    }
    response1 = requests.request("POST", url1, json=payload1, headers=headers)
    resp1 = json.loads(response1.text)
    segments = resp1["segments"]
    if len(segments) == 0:
        continue
    payload2 = {"segments": segments}
    url2 = "https://studio.edgeimpulse.com/v1/api/{}/raw-data/{}/segment".format(projectId, sampleId)
    response2 = requests.request("POST", url2, json=payload2, headers=headers)
    logging.info('{} {} {}'.format(tid, sampleId, response2.text))

if __name__ == "__main__":
    format = "%(asctime)s: %(message)s"
    logging.basicConfig(format=format, level=logging.INFO,
    datefmt="%H:%M:%S")
    querystring = {"category":"testing", "excludeSensors":"true"}
    url = "https://studio.edgeimpulse.com/v1/api/{}/raw-data".format(projectId)
    response = requests.request("GET", url, headers=headers, params=querystring)
    resp = json.loads(response.text)
    id_list = list(map(lambda s: s["id"], resp["samples"]))
    div = 8
    n = int(len(id_list) / div)
    threads = list()
    for i in range(div):
    if i == (div - 1):
        ids = id_list[n*i: ]
    else:
        ids = id_list[n*i: n*(i+1)]

    x = threading.Thread(target=segment, args=(i, ids))
    threads.append(x)
    x.start()
    for thread in threads:
        thread.join()
    logging.info("Finished")

训练
转到 Impulse Design > Create Impulse 页面,然后单击Add a processing block并选择Spectrogram ,这是一种表示信号强度或“响度”随时间在特定波形中存在的各种频率的可视化方式。此外,在同一页面上单击添加学习块并选择从数据中学习模式的神经网络(Keras),并将其应用于新数据。我们选择了 1000 毫秒窗口大小和 125 毫秒窗口增加。现在单击“保存冲动”按钮。

现在转到 Impulse Design > Spectrogram 页面并更改参数,如下图所示,然后单击Save parameters按钮。我们选择了 Frame Length = 0.02s,frame stride = 0.01538s 和。频带 = 128(FFT 大小),并且本底噪声 = -54 dB。本底噪声用于滤除频谱图中的背景噪声。它首先将窗口划分为多个重叠的帧。可以通过参数Frame length和Frame stride调整帧的大小和数量. 例如,窗口为 1000 毫秒,帧长为 20 毫秒,步幅为 15.38 毫秒,它将创建 65 个时间帧。然后使用 FFT(快速傅立叶变换)将每个时间帧划分为频率区间,然后我们计算其功率谱。频率区间的数量等于频段参数除以 2 加 1。频谱图块生成的特征等于生成的时间帧数乘以频率区间的数量。

单击“保存参数”按钮重定向到另一个页面,我们应该在其中单击“生成特征”按钮。完成特征生成通常需要几分钟。我们可以在 Feature Explorer 中看到生成的特征的 3D 可视化。

现在转到 Impulse Design > NN Classifier 页面并从下拉菜单中选择 Switch to Keras (expert) mode 并定义模型架构。有许多现成的音频分类模型可用,但它们具有大量参数,因此不适用于 256KB 或更少内存的微控制器。经过大量试验,我们创建了如下所示的模型架构。

import tensorflow as tf
from tensorflow.keras.models import Sequential
from tensorflow.keras.layers import Reshape, Conv2D, Flatten, ReLU, Dropout, MaxPooling2D, Dense
from tensorflow.keras.optimizers.schedules import InverseTimeDecay
from tensorflow.keras.optimizers import Adam
from tensorflow.keras.layers.experimental import preprocessing

sys.path.append('./resources/libraries')
import ei_tensorflow.training

channels = 1
columns = 65
rows = int(input_length / (columns * channels))

norm_layer = preprocessing.Normalization()
norm_layer.adapt(train_dataset.map(lambda x, _: x))

# model architecture
model = Sequential()
model.add(Reshape((rows, columns, channels), input_shape=(input_length, )))
model.add(preprocessing.Resizing(24, 24, interpolation='nearest'))
model.add(norm_layer)

model.add(Conv2D(16, kernel_size=3))
#model.add(BatchNormalization())
#model.add(Activation('relu'))
model.add(ReLU(6.0))

model.add(Conv2D(32, kernel_size=3))
#model.add(BatchNormalization())
#model.add(Activation('relu'))
model.add(ReLU(6.0))
model.add(MaxPooling2D(pool_size=2, strides=2, padding='same'))
model.add(Dropout(0.7))

model.add(Flatten())

model.add(Dense(64))
#model.add(BatchNormalization())
#model.add(Activation('relu'))
model.add(ReLU(6.0))
#model.add(Dropout(0.50))

model.add(Dense(32))
#model.add(BatchNormalization())
#model.add(Activation('relu'))
model.add(ReLU(6.0))
#model.add(Dropout(0.50))

model.add(Dense(classes, activation='softmax', name='y_pred'))

BATCH_SIZE = 64

lr_schedule = InverseTimeDecay(
  0.0005,
  decay_steps=train_sample_count//BATCH_SIZE*15,
  decay_rate=1,
  staircase=False)

def get_optimizer():
  return Adam(lr_schedule)

train_dataset = train_dataset.batch(BATCH_SIZE, drop_remainder=False)
validation_dataset = validation_dataset.batch(BATCH_SIZE, drop_remainder=False)
callbacks.append(BatchLoggerCallback(BATCH_SIZE, train_sample_count))

# train the neural network
model.compile(loss='categorical_crossentropy', optimizer=get_optimizer(), metrics=['accuracy'])

print(model.summary())

model.fit(train_dataset, epochs=70, validation_data=validation_dataset, verbose=2, callbacks=callbacks)

在定义模型架构时,我们已尽力针对 TinyML 用例对其进行优化。由于 64x65 单通道频谱图特征将具有大量训练参数,并且编译后的模型不适合可用的微控制器 RAM,我们将频谱图调整为 24x24 大小,这是模型大小与精度的最佳选择。此外,我们使用了受限范围激活 (ReLU6),因为 ReLU6 将输出限制为 [0, 6],并且训练后量化不会降低准确性。模型概要如下。

Model: "sequential"
_________________________________________________________________ 
Layer (type)                 Output Shape              Param #    
================================================================= 
reshape (Reshape)            (None, 64, 65, 1)         0          
_________________________________________________________________ 
resizing (Resizing)          (None, 24, 24, 1)         0         
 _________________________________________________________________ 
normalization (Normalization (None, 24, 24, 1)         3          
_________________________________________________________________ 
conv2d (Conv2D)              (None, 22, 22, 16)        160        
_________________________________________________________________ 
re_lu (ReLU)                 (None, 22, 22, 16)        0          
_________________________________________________________________ 
conv2d_1 (Conv2D)            (None, 20, 20, 32)        4640       
_________________________________________________________________ 
re_lu_1 (ReLU)               (None, 20, 20, 32)        0          
_________________________________________________________________ 
max_pooling2d (MaxPooling2D) (None, 10, 10, 32)        0          
_________________________________________________________________ 
dropout (Dropout)            (None, 10, 10, 32)        0          
_________________________________________________________________ 
flatten (Flatten)            (None, 3200)              0          
_________________________________________________________________ 
dense (Dense)                (None, 64)                204864     
_________________________________________________________________ 
re_lu_2 (ReLU)               (None, 64)                0          
_________________________________________________________________ 
dense_1 (Dense)              (None, 32)                2080       
_________________________________________________________________ 
re_lu_3 (ReLU)               (None, 32)                0          
_________________________________________________________________ 
y_pred (Dense)               (None, 2)                 66         
================================================================= 
Total params: 211,813 
Trainable params: 211,810 
Non-trainable params: 3

现在单击开始训练按钮并等待大约一个小时直到训练完成。我们可以在下面看到训练输出。该模型具有 94.6% 的准确率。

测试
我们可以在测试中测试模型。数据集通过转到模型测试页面并单击分类所有按钮。该模型在测试数据集上的准确率为 88.58%。

部署
由于我们将在 Arduino Nano BLE sense 部署模型,因此在部署页面我们将选择创建库 > Arduino选项。对于Select optimization选项,我们将选择Enable EON Compiler以减少模型的内存使用。此外,我们将选择量化 (Int8) 模型。现在单击Build按钮,几秒钟后库包将下载到本地计算机。

硬件设置
我们将使用带有板载麦克风的 Arduino Nano 33 BLE Sense。由于 Arduino Nano 33 BLE Sense 上的 5V 引脚默认断开连接,要使用 5V 引脚为振动电机供电,我们需要在标记为 VUSB 的两个焊盘上制作一个焊桥(下图中的红色矩形突出显示)。

振动电机使用直接焊接在 Arduino Nano BLE 传感头引脚上的 Grove 连接器连接。原理图可以在下文原理图部分找到。

运行推理
请按照此处的说明下载并安装 Arduino IDE。安装后,打开 Arduino IDE 并通过转到工具 > 开发板 > 开发板管理器为 Arduino Nano 33 BLE Sense 安装开发板包。搜索如下图的板子包并安装。

板包安装完成后,从 Tools > Board > Arduino Mbed OS Nano Boards 菜单中选择 Arduino Nano 33 BLE。另外,从工具>端口菜单中选择连接的开发板的串口。我们需要使用库管理器(工具 > 管理库...)安装RingBuffer库

下面是推理的代码。应用程序使用双缓冲区连续捕获音频事件。

// If your target is limited in memory remove this macro to save 10K RAM
#define EIDSP_QUANTIZE_FILTERBANK   0

/**
   Define the number of slices per model window. E.g. a model window of 1000 ms
   with slices per model window set to 4. Results in a slice size of 250 ms.
   For more info: https://docs.edgeimpulse.com/docs/continuous-audio-sampling
*/
#define EI_CLASSIFIER_SLICES_PER_MODEL_WINDOW 3

/* Includes ---------------------------------------------------------------- */
#include <PDM.h>
#include <Scheduler.h>
#include <RingBuf.h>
#include <snore_detection_inferencing.h>

/** Audio buffers, pointers and selectors */
typedef struct {
  signed short *buffers[2];
  unsigned char buf_select;
  unsigned char buf_ready;
  unsigned int buf_count;
  unsigned int n_samples;
} inference_t;

static inference_t inference;
static bool record_ready = false;
static signed short *sampleBuffer;
static bool debug_nn = false; // Set this to true to see e.g. features generated from the raw signal
static int print_results = -(EI_CLASSIFIER_SLICES_PER_MODEL_WINDOW);

bool alert = false;

RingBuf<uint8_t, 10> last_ten_predictions;
int greenLED = 23;
int vibratorPin = 3;   // Vibration motor connected to D3 PWM pin
bool is_motor_running = false;

void run_vibration()
{
  if (alert)
  {
    is_motor_running = true;

    for (int i = 0; i < 2; i++)
    {
      analogWrite(vibratorPin, 30);
      delay(1000);
      analogWrite(vibratorPin, 0);
      delay(1500);
    }
    
    is_motor_running = false;
  } else {
    if (is_motor_running)
    {
      analogWrite(vibratorPin, 0);
    }
  }
  yield();
}

/**
   @brief      Printf function uses vsnprintf and output using Arduino Serial

   @param[in]  format     Variable argument list
*/
void ei_printf(const char *format, ...) {
  static char print_buf[1024] = { 0 };

  va_list args;
  va_start(args, format);
  int r = vsnprintf(print_buf, sizeof(print_buf), format, args);
  va_end(args);

  if (r > 0) {
    Serial.write(print_buf);
  }
}

/**
   @brief      PDM buffer full callback
               Get data and call audio thread callback
*/
static void pdm_data_ready_inference_callback(void)
{
  int bytesAvailable = PDM.available();

  // read into the sample buffer
  int bytesRead = PDM.read((char *)&sampleBuffer[0], bytesAvailable);

  if (record_ready == true) {
    for (int i = 0; i<bytesRead >> 1; i++) {
      inference.buffers[inference.buf_select][inference.buf_count++] = sampleBuffer[i];

      if (inference.buf_count >= inference.n_samples) {
        inference.buf_select ^= 1;
        inference.buf_count = 0;
        inference.buf_ready = 1;
      }
    }
  }
}

/**
   @brief      Init inferencing struct and setup/start PDM

   @param[in]  n_samples  The n samples

   @return     { description_of_the_return_value }
*/
static bool microphone_inference_start(uint32_t n_samples)
{
  inference.buffers[0] = (signed short *)malloc(n_samples * sizeof(signed short));

  if (inference.buffers[0] == NULL) {
    return false;
  }

  inference.buffers[1] = (signed short *)malloc(n_samples * sizeof(signed short));

  if (inference.buffers[0] == NULL) {
    free(inference.buffers[0]);
    return false;
  }

  sampleBuffer = (signed short *)malloc((n_samples >> 1) * sizeof(signed short));

  if (sampleBuffer == NULL) {
    free(inference.buffers[0]);
    free(inference.buffers[1]);
    return false;
  }

  inference.buf_select = 0;
  inference.buf_count = 0;
  inference.n_samples = n_samples;
  inference.buf_ready = 0;

  // configure the data receive callback
  PDM.onReceive(&pdm_data_ready_inference_callback);

  PDM.setBufferSize((n_samples >> 1) * sizeof(int16_t));

  // initialize PDM with:
  // - one channel (mono mode)
  // - a 16 kHz sample rate
  if (!PDM.begin(1, EI_CLASSIFIER_FREQUENCY)) {
    ei_printf("Failed to start PDM!");
  }

  // set the gain, defaults to 20
  PDM.setGain(127);

  record_ready = true;

  return true;
}

/**
   @brief      Wait on new data

   @return     True when finished
*/
static bool microphone_inference_record(void)
{
  bool ret = true;

  if (inference.buf_ready == 1) {
    ei_printf(
      "Error sample buffer overrun. Decrease the number of slices per model window "
      "(EI_CLASSIFIER_SLICES_PER_MODEL_WINDOW)\n");
    ret = false;
  }

  while (inference.buf_ready == 0) {
    delay(1);
  }

  inference.buf_ready = 0;

  return ret;
}

/**
   Get raw audio signal data
*/
static int microphone_audio_signal_get_data(size_t offset, size_t length, float * out_ptr)
{
  numpy::int16_to_float(&inference.buffers[inference.buf_select ^ 1][offset], out_ptr, length);

  return 0;
}

/**
   @brief      Stop PDM and release buffers
*/
static void microphone_inference_end(void)
{
  PDM.end();
  free(inference.buffers[0]);
  free(inference.buffers[1]);
  free(sampleBuffer);
}


void setup()
{
  Serial.begin(115200);

  pinMode(greenLED, OUTPUT);
  pinMode(greenLED, LOW); 
  pinMode(vibratorPin, OUTPUT);  // sets the pin as output

  // summary of inferencing settings (from model_metadata.h)
  ei_printf("Inferencing settings:\n");
  ei_printf("\tInterval: %.2f ms.\n", (float)EI_CLASSIFIER_INTERVAL_MS);
  ei_printf("\tFrame size: %d\n", EI_CLASSIFIER_DSP_INPUT_FRAME_SIZE);
  ei_printf("\tSample length: %d ms.\n", EI_CLASSIFIER_RAW_SAMPLE_COUNT / 16);
  ei_printf("\tNo. of classes: %d\n", sizeof(ei_classifier_inferencing_categories) /
            sizeof(ei_classifier_inferencing_categories[0]));

  run_classifier_init();
  if (microphone_inference_start(EI_CLASSIFIER_SLICE_SIZE) == false) {
    ei_printf("ERR: Failed to setup audio sampling\r\n");
    return;
  }

  Scheduler.startLoop(run_vibration);
}

void loop()
{

  bool m = microphone_inference_record();

  if (!m) {
    ei_printf("ERR: Failed to record audio...\n");
    return;
  }

  signal_t signal;
  signal.total_length = EI_CLASSIFIER_SLICE_SIZE;
  signal.get_data = &microphone_audio_signal_get_data;
  ei_impulse_result_t result = {0};

  EI_IMPULSE_ERROR r = run_classifier_continuous(&signal, &result, debug_nn);
  if (r != EI_IMPULSE_OK) {
    ei_printf("ERR: Failed to run classifier (%d)\n", r);
    return;
  }

  if (++print_results >= (EI_CLASSIFIER_SLICES_PER_MODEL_WINDOW)) {
    // print the predictions
    ei_printf("Predictions ");
    ei_printf("(DSP: %d ms., Classification: %d ms., Anomaly: %d ms.)",
              result.timing.dsp, result.timing.classification, result.timing.anomaly);
    ei_printf(": \n");

    for (size_t ix = 0; ix < EI_CLASSIFIER_LABEL_COUNT; ix++) {
      ei_printf("    %s: %.5f\n", result.classification[ix].label,
                result.classification[ix].value);

      if (ix == 1 && !is_motor_running && result.classification[ix].value > 0.9) {
        if (last_ten_predictions.isFull()) {
          uint8_t k;
          last_ten_predictions.pop(k);
        }

        last_ten_predictions.push(ix);

        uint8_t count = 0;

        for (uint8_t j = 0; j < last_ten_predictions.size(); j++) {
          count += last_ten_predictions[j];
          //ei_printf("%d, ", last_ten_predictions[j]);
        }
        //ei_printf("\n");
        ei_printf("Snoring\n");
        pinMode(greenLED, HIGH); 
        if (count >= 5) {
          ei_printf("Trigger vibration motor\n");
          alert = true;
        }
      }  else {
        ei_printf("Noise\n");
        pinMode(greenLED, LOW); 
        alert = false;
      }

      print_results = 0;
    }
  }
}


#if !defined(EI_CLASSIFIER_SENSOR) || EI_CLASSIFIER_SENSOR != EI_CLASSIFIER_SENSOR_MICROPHONE
#error "Invalid model for current sensor."
#endif

要运行推理草图,请使用以下命令克隆应用程序存储库。

$ git clone https://github.com/metanav/Snoring_Guardian.git

在 Arduino IDE 中打开 Arduino 草图 Snoring_Guardian/snoring_detection_inferencing/examples/tflite_micro_snoring_detection/tflite_micro_snoring_detection.ino。编译并上传固件到连接的开发板。我们可以使用波特率 115200 bps 的 Tools > Serial Monitor 查看推理输出。

视频演示:

套管
最终版本的设备被放置在一个带有移动电源的袋子里。小袋中有一个小开口,可以让位于开口附近的麦克风听到声音。

现场演示

原理图:

源代码:点击下载

总结
这个项目为一个现实生活中的问题提供了一个解决方案,这个问题看起来很有趣但需要仔细关注。它是一种易于使用且方便的设备,通过在边缘运行推理来尊重用户的隐私。该项目还展示了一个简单的神经网络可以通过以正确的方式进行信号处理来解决复杂的问题,并且可以在低功耗资源受限的微型设备上运行。尽管 TensorFlow Lite Micro 模型运行良好,但仍有改进的空间。随着更多的训练数据,模型可以变得更加准确和健壮。

加入微信技术交流群

技术交流,职业进阶

关注与非网服务号

获取电子工程师福利

加入电路城 QQ 交流群

与技术大牛交朋友

讨论