一、实现了什么功能
本项目主要实现了Funpack第12期中的任务三的功能,使用麦克风采集使用者说话口型并识别,同时在屏幕上画出对应的表情。
二、主要代码片段及说明
本项目的主要开发工作可以分为模型构建和应用开发两个阶段。首先,使用Edge Impulse平台完成口型音频数据采集、特征提取、模型训练并生成可以在Arduino上部署的神经网络推理库。然后,在Wio Terminal上完成使用麦克风采集使用者说话口型并识别,同时在屏幕上画出对应的表情。
2.1 模型构建
本次项目的神经网络模型构建使用了Edge Impulse平台,它是一个覆盖完整机器学习流程的嵌入式TinyML开发平台,可以帮助嵌入式工程师轻松的完成数据采集、特征提取、模型训练等机器学习任务!Edge Impulse在Coursera上推出了一门免费的课程Introduction to Embedded Machine Learning,涵盖了嵌入式机器学习和Edge Impulse平台使用的课程,可以帮助初学者快速入门嵌入式机器学习。
2.1.1 数据集
本项目需要采集说话人口型(a、e、i、o、u)对应的音频数据,基于Edge Impulse可以通过手机采集、开发板采集、本地上传等多种方式采集数据。由于使用手机和开发板采集数据速度比较慢,所以使用了Kaggle上的DATASET_OF_VOWELS数据集为基础,再配合部分设备采集的数据构成了本次项目所需使用的数据集。除了a、e、i、o、u对应了5类口型数据以外还加入了表示静音的_silence音频数据,一共有6类数据,大致按照80/20的比例划分为训练集和测试集。
使用Edge Impulse可以轻松地预览数据,打标签和剪裁数据。
2.1.2 模型设计
Edge Impulse提供了多种内置的预处理和学习模块,本项目选择了MFE预处理模块,和神经网络学习模块。MFE用于提取口型音频数据中的梅尔能量特征,神经网络用于学习和将MFE提取的特征进行分类。
通过MFE预处理模块的特征3维可视化,可以发现MFE还是可以较为有效提取出不同口型音频对应的特性。
由于数据集中收集的数据量比较少,并且每类数据的样本数不是太一样,所以在模型训练的页面勾选了自动平衡数据集和数据增强训练来帮助提升模型的性能。
受限于嵌入式设备的存储资源和性能,在嵌入式设备上运行的神经网络模型一般都比较简单,本项目使用了主体为3层一维卷积神经网络组成的小网络进行口型音频数据分类。通过Edge Impulse的图形化界面,可以很方便的构建神经网络。
2.1.3 模型训练
完成了神经网络设计之后直接在Edge Impulse平台上进行模型训练,训练完的int8量化模型在验证集上准确率达到96%,看起来还可以。
2.1.4 模型测试
使用测试集对模型进行测试,准确率也达到了95%。
2.1.5 模型部署
在部署页面,Edge Impulse将预处理模块、学习模块和模型数据打包为推理库,提供神经网络推理接口用于应用开发,为了在Wio Terminal上部署模型,将推理库构建为Arduino的库。
使用int8量化的模型在Wio Terminal上进行推理,预期将占用8.7k RAM,46.6k Flash,进行一次推理的延迟为13ms。
2.2 应用开发
本项目的应用程序使用VS Code的PlatformIO进行开发,可以获得比Arduino IDE更好的开发体验。PlatformIO可以帮助我们方便的安装和管理各种Arduino的库,例如,可以使用以下的命令将Edge Impulse构建的机器学习推理库安装到项目里:
pio lib install xxxxxxxxxx.zip
2.2.1 模型推理
Edge Impulse已经将特征处理,模型推理的功能封装成了可以直接调用的API接口,同时还提供了不同开发板上的Example供大家参考,本项目程序的主体来源于Edge Impulse提供的nano_ble33_sense_microphone_continuous.ino示例,参考wio terminal wiki页面上提供的Audio Scene Recognition教程进行了修改以适配wio terminal的音频采集。音频数据采用了DMA方式进行采集,可以直接将数据采集到推理输入数据缓冲区中,而无需MCU的参与。详细的内容可以查看教程学习,这里不再赘述。
本项目采用了连续数据采集的方式进行模型推理,收集到一部分的数据之后就会进行一部分特征提取工作,而不是采集到全部的数据之后进行特征提取工作,降低了音频特征提取所需的时延。程序的主要流程分为以下步骤:
- 各种初始化操作(串口、LCD、推理库)。
- 循环进行数据采集和模型推理。
- 根据模型推理的结果在LCD屏幕上进行打印。
考虑到后续添加新的功能,程序使用了FreeRTOS的进行了多线程开发(尽管目前只用了一个线程),主线程的主要代码如下所示:
static void infer_task(void* pvParameters)
{
static float vowel_a_prev = 0.0;
static float vowel_e_prev = 0.0;
static float vowel_i_prev = 0.0;
static float vowel_o_prev = 0.0;
static float vowel_u_prev = 0.0;
static label_t idx_prev = LABEL_NONE;
label_t idx = LABEL_NONE;
inference_init();
while (1)
{
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 = µphone_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;
}
// Calculate 2-point moving average filter (MAF) for a label
float vowel_a_val = result.classification[1].value;
float vowel_a_maf = (vowel_a_prev + vowel_a_val) / 2;
vowel_a_prev = vowel_a_val;
// Calculate 2-point moving average filter (MAF) for e label
float vowel_e_val = result.classification[2].value;
float vowel_e_maf = (vowel_e_prev + vowel_e_val) / 2;
vowel_e_prev = vowel_e_val;
// Calculate 2-point moving average filter (MAF) for i label
float vowel_i_val = result.classification[3].value;
float vowel_i_maf = (vowel_i_prev + vowel_i_val) / 2;
vowel_i_prev = vowel_i_val;
// Calculate 2-point moving average filter (MAF) for o label
float vowel_o_val = result.classification[4].value;
float vowel_o_maf = (vowel_o_prev + vowel_o_val) / 2;
vowel_o_prev = vowel_o_val;
// Calculate 2-point moving average filter (MAF) for u label
float vowel_u_val = result.classification[5].value;
float vowel_u_maf = (vowel_u_prev + vowel_u_val) / 2;
vowel_u_prev = vowel_u_val;
// Figure out if any MAF values surpass threshold
if (vowel_a_maf > maf_threshold)
{
idx = VOWEL_A;
}
else if (vowel_e_maf > maf_threshold)
{
idx = VOWEL_E;
}
else if (vowel_i_maf > maf_threshold)
{
idx = VOWEL_I;
}
else if (vowel_o_maf > maf_threshold)
{
idx = VOWEL_O;
}
else if (vowel_u_maf > maf_threshold)
{
idx = VOWEL_U;
}
else
{
idx = _SILENCE;
}
// Print label to LCD if predicted class is different from last iteration
if (idx != idx_prev)
{
switch (idx)
{
case VOWEL_A:
lcd_print(g_image_a, "VOWEL A");
break;
case VOWEL_E:
lcd_print(g_image_e, "VOWEL E");
break;
case VOWEL_I:
lcd_print(g_image_i, "VOWEL I");
break;
case VOWEL_O:
lcd_print(g_image_o, "VOWEL O");
break;
case VOWEL_U:
lcd_print(g_image_u, "VOWEL U");
break;
case _SILENCE:
lcd_print(g_image_silence, "SILENCE");
break;
case LABEL_NONE:
break;
}
}
idx_prev = idx;
}
}
处理数据采集和模型推理以外,程序还使用了MAF来对模型推理的结果进行过滤,降低识别的错误率。
2.2.2 LCD显示
当没有检测到人声时,LCD屏幕默认显示SILENCE和不张嘴的表情,当检测到人声后标签进行更新后,LCD将显示对应的口型图片和标签的值。由于从SD卡加载彩色图片并显示在LCD屏幕上需要较长的时间,可能会影响连续音频数据的识别过程,因此本项目只在LCD屏幕上显示了单色的bitmaps图片和对应的标签字符串的值(显示彩色图片会导致系统Crash,没有找到解决的办法,搞不出来)。本项目显示的图片是Father Bear Phonetics Sheet by dagracey on DeviantArt,下载于Pinterest网站,通过软件转换为在LCD上显示的bitmaps图片(M对应的表情没有张嘴巴,所以它在本项目里代表无人声)。
LCD显示图片和标签的代码如下所示:
void lcd_print(const uint8_t* bitmap, const char* label)
{
// Disable recording for 1-second hold-off
recording = 0;
tft.fillScreen(TFT_WHITE);
tft.drawBitmap(((LCD_WIDTH - IMAGE_WIDTH) / 2), 0, bitmap, IMAGE_WIDTH, IMAGE_HEIGHT, TFT_PINK);
tft.setTextColor(TFT_PINK, TFT_WHITE);
uint32_t poX = ((LCD_WIDTH - IMAGE_WIDTH) / 2) - 12;
uint32_t poY = IMAGE_HEIGHT;
tft.drawString(label, poX, poY);
delay(10);
recording = 1;
}
三、功能展示及说明
- 默认画面
2. 口型-a
3. 口型-e
4. 口型-i
5. 口型-o
6. 口型-u
四、对本活动的心得体会
首先要感谢活动主办方举办那么有意思的活动,让对电子有兴趣的大家可以聚集在一起玩出那么多很酷的项目。其次,要感谢Seeed、Arduino和Edge Impulse等组织和机构,提供了许多意义重大又能快速使用的平台、工具和库,帮助每个人都可以简单快速的实现自己的电子梦想。最后,由于时间关系(工作)的影响,本项目仍然有许多的缺陷和可以提高的地方,比如说除了五个元音和无声的数据之外还缺乏了噪音和未知音频的数据类别,用于训练的数据量也不够充足,在真实环境里识别的准确率还有很大提升空间,LCD显示的实现也比较简单,后续有时间的话还需要继续完善。
五、放在最后
本项目的代码在Github上开源,希望喜欢这个项目的同学能够star一下。
yorange1/Funpack12: Funpack12 - Wio Terminal (github.com)