1. 项目介绍
有许多家庭,由于工作繁忙,导致老人独自在家,难免会发生磕磕碰碰和一些紧急情况。MAX78000FTHR集成了基于硬件的卷积神经网络(CNN)加速器可以执行AI推理(使用预训练的模型)在非常低的能量水平,实现关键词的识别。刚好可以很好的解决这个问题,让发生意外的老年人能够第一时间将自己的情况传达出去,来获得紧急的救助。
2. 项目设计思路
在电脑上训练神经网络,部署到单片机上运行,使用芯片内置的CNN神经网络加速器训练、部署神经网络。然后将识别到的信息通过串口发送到ESP32上,用ESP32上的WiFi发送到电脑上。 主要参考板卡官方的Github仓库:
ai8x-training仓库:用于在电脑上训练神经网络
ai8x-synthesis仓库:用于把训练好的模型文件转换成c语言代码
msdk仓库:用于编写单片机程序
2.1 软件部分
在官方的msdk仓库中有官方的例程,此次设计主要用到两个例程,uart串口和kws20_demo。uart用于与ESP32相连,kws20_demo用于识别出结果。分别先跑一下这些例程来初步学习一下Max78000这块板子。
2.1.1 串口
将P1.0与P2.7引脚相连,可以看到打印出信息,串口3的TX与串口2的RX相连,波特率为115200。
仔细看一下代码,地址索引
#define MXC_UART_GET_UART(i) \
((i) == 0 ? MXC_UART0 : (i) == 1 ? MXC_UART1 : (i) == 2 ? MXC_UART2 : (i) == 3 ? MXC_UART3 : 0)
根据给定的索引(i),宏通过条件表达式来选择对应的UART外设地址。如果索引为0,返回MXC_UART0的地址;如果索引为1,返回MXC_UART1的地址;如果索引为2,返回MXC_UART2的地址;如果索引为3,返回MXC_UART3的地址。如果索引不在0到3之间,返回0。
struct _mxc_uart_req_t {
mxc_uart_regs_t *uart; ///<Point to UART registers
const uint8_t *txData; ///< Buffer containing transmit data. For character sizes
///< < 8 bits, pad the MSB of each byte with zeros. For
///< character sizes > 8 bits, use two bytes per character
///< and pad the MSB of the upper byte with zeros
uint8_t *rxData; ///< Buffer to store received data For character sizes
///< < 8 bits, pad the MSB of each byte with zeros. For
///< character sizes > 8 bits, use two bytes per character
///< and pad the MSB of the upper byte with zeros
uint32_t txLen; ///< Number of bytes to be sent from txData
uint32_t rxLen; ///< Number of bytes to be stored in rxData
volatile uint32_t txCnt; ///< Number of bytes actually transmitted from txData
volatile uint32_t rxCnt; ///< Number of bytes stored in rxData
表2-1 UART(通用异步收发传输器)相关的变量和指针
变量名 | 功能 |
txData | 指向传输数据缓冲区的指针,存储要发送的数据。对于字符大小小于8位的情况,每个字节的最高位应填充为零。对于字符大小大于8位的情况,每个字符使用两个字节,最高位填充为零。 |
rxData | 指向接收数据缓冲区的指针,用于存储接收到的数据。对于字符大小小于8位的情况,每个字节的最高位应填充为零。对于字符大小大于8位的情况,每个字符使用两个字节,最高位填充为零。 |
txLen | 要从txData发送的字节数。 |
rxLen | 要存储在rxData中的字节数。 |
txCnt | 实际从txData中传输的字节数,是一个volatile类型的变量,可能会在中断等异步操作中被修改。 |
rxCnt | 存储在rxData中的字节数,是一个volatile类型的变量,可能会在中断等异步操作中被修改。 |
callback | 指向在传输完成时调用的回调函数的指针。 可以通过设置这些变量来实现串口通信。 |
2.2.2 kws20_demo
kws20_demo预设了20个词可以识别:'up', 'down', 'left', 'right', 'stop', 'go', 'yes', 'no', 'on', 'off', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'zero',将串口调试助手的波特率设置为115200,说出对应单词可以看到识别结果和它的置信度。
图 2-1 KWS20演示的软件组件
图 2-2 KWS20 Demo固件中的处理过程
研究了一下它的工作过程,发现从mic/file收集的样本是18/16位有符号的,转换为8位有符号以馈送到CNN。在麦克风模式下,高通滤波器滤除捕获样本中的直流电平。缩放的样本以128个样本(字节)块的形式存储在micBuff循环缓冲区中。
2.2 硬件部分
Max78000FTHR负责识别从mic收集到的信息,要实现与其他设备无线通信,ESP32集成了Wi-Fi和蓝牙,很好的满足了这个要求。
图2-3 ESP32
Max78000FTHR与ESP32之间进行串口通信,利用ESP32建立TCP服务端,就可以把识别的信息发送到电脑上。
3. 搜集素材的思路
3.1 原始素材录制
录制音频的质量很重要,直接决定了识别的成功率,试过很多种办法,如用Audacity来录制,它的优点是可以看到录制音频的波形,缺点是时间不好控制,不容易得到训练所需要的要求。在踩过很多坑后,发现官方给出了方法,很好用。 使用给出的VoiceRecorder.py脚本多次说出一个单词,大约每秒一个单词,语调和速度不同,人也不同。然后将其放在INPUT_FOLDER中,使用convert_segment_wav.py脚本将其分割为1秒16kHz采样的wav文件,需要包: matplotlib==3.5.3 numpy==1.23.2 pyaudio==0.2.12 sounddevice==0.4.5 SoundFile==0.10.3.post1
VoiceRecorder.py
import wave
import argparse
import pyaudio
SR = 16000 # sample per sec
CHANNEL = 1 # number of input channel
FORMAT = pyaudio.paInt16 # data format
CHUNK = 1024
LENGTH = 4
def audio_recorder(filename='output.wav', recordlength=LENGTH, samplerate=SR):
"""
Record audio to a file.
"""
audio = pyaudio.PyAudio()
print("Recording started for", recordlength, " sec")
stream = audio.open(format=FORMAT,
channels=CHANNEL,
rate=samplerate,
input=True,
frames_per_buffer=CHUNK)
frame = []
for i in range(0, int(samplerate / CHUNK * recordlength)):
data = stream.read(CHUNK)
frame.append(data)
print(i)
print("Recording finished!")
stream.stop_stream()
stream.close()
audio.terminate()
# Store in file
wf = wave.open(filename, 'wb')
wf.setnchannels(CHANNEL)
wf.setsampwidth(audio.get_sample_size(FORMAT))
wf.setframerate(samplerate)
wf.writeframes(b''.join(frame))
wf.close()
def command_parser():
"""
Return the argument parser
"""
parser = argparse.ArgumentParser(description='Audio recorder command parser')
parser.add_argument('-d', '--duration', type=int, default=LENGTH,
help='audio recording duration (default:' + LENGTH.__str__() + ')')
parser.add_argument('-sr', '--samplerate', type=int, default=SR,
help='recording samplerate (default:' + SR.__str__() + ')')
parser.add_argument('-o', '--output', type=str, default='voice.wav',
help='output wavefile name')
return parser.parse_args()
if __name__ == "__main__":
command = command_parser()
print("Output Name = ", command.output)
print("Sample Rate = ", command.samplerate)
print("Duration = ", command.duration)
audio_recorder(command.output, command.duration, command.samplerate)
convert_segment_wav.py
import errno
import os
import argparse
import soundfile as sf
import numpy as np
import librosa
THRESHOLD = 30 # threshold to detect the beginning on an utterance
def resample(folder_in, folder_out, sr=16384):
"""
Detects utterances in the input audio files and creates 1-sec 16khz
mono .wav files as needed for KWS dataset
"""
# create output folder
try:
os.mkdir(folder_out)
except OSError as e:
if e.errno == errno.EEXIST:
pass
else:
raise
print(f'{folder_out} already exists. overwrite!')
for (dirpath, _, filenames) in os.walk(folder_in):
for filename in sorted(filenames):
file_cnt = 0
i = 0
if filename.endswith('.wav') or filename.endswith('.ogg'):
fname = os.path.join(dirpath, filename)
data, samplerate = librosa.load(fname, sr=sr)
print(f'\rProcessing {fname}, sample rate={samplerate}', end=" ")
mx = np.amax(abs(data))
data = data/mx
chunk_start = 0
segment_len = 98*128
while True:
if chunk_start + segment_len > len(data):
break
chunk = data[chunk_start: chunk_start+128]
avg = 1000*np.average(abs(chunk))
# visualize:
# bars = "=" * int(100 * avg/100)
# peak = avg * 100
# print("%04d %05d %s" % (i, peak, bars))
i += 128
if avg > THRESHOLD and chunk_start >= 30*128:
frame = data[chunk_start - 30*128:chunk_start + 98*128]
outfile = os.path.join(folder_out, filename[:-4] + str(file_cnt) + ".wav")
sf.write(outfile, frame, sr)
file_cnt += 1
chunk_start += 98*128
else:
chunk_start += 128
else:
continue
print('\r')
def command_parser():
"""
Return the argument parser
"""
parser = argparse.ArgumentParser(description='Audio recorder command parser')
parser.add_argument('-i', '--input', type=str, default='InputFolder', required=False,
help='input folder with audio files')
parser.add_argument('-o', '--output', type=str, required=False, default='OutputWav',
help='output folder for segmented and resampled audio files')
return parser.parse_args()
if __name__ == "__main__":
command = command_parser()
resample(command.input, command.output)
3.2 数据集
在ai8x-training/data/KWS/raw中为新的关键字标签创建一个文件夹,我这里选取了四个常用的紧急情况号码和两个控制指令,复制此文件夹中的所有样本。 将ai8x-training/data/KWS/processing删除,让数据加载器使用其他标签重新创建数据集。 数据集的目录如下:
|--data
|--KWS
|--raw
|--a110
|--a119
|--a120
|--a551
|--go
|--stop
3.3 更新Data Loader
更新 ai8x-training/datasets/kws20.py: 将新标签添加到已排序的类字典中,此处需要对其排序,并具有唯一的增量值:
class_dict = {'a110': 0, 'a119': 1, 'a120': 2, 'a551': 3, 'backward': 4, 'bed': 5, 'bird': 6, 'cat': 7, 'dog': 8, 'down': 9,....
将新类添加到KWS_get_datasets函数到所需关键字列表中(此列表不需要排序)。所有其他标签将融合在“未知”类中:
if num_classes == 6:
classes = ['a110', 'a119', 'a120', a551', 'stop', 'go']
elif num_classes == 20:
classes = ['up', 'down', 'left', 'right', 'stop', 'go', 'yes', 'no', 'on', 'off', 'one',
'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'zero']
elif num_classes == 21: # additional keyword 'hello'
classes = ['up', 'down', 'left', 'right', 'stop', 'go', 'yes', 'no', 'on', 'off', 'one',
'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'zero', 'hello'] # 'hello' is class 20, unknown is 21
更新KWS_20_get_datasets函数以返回新的类数。 return KWS_get_datasets(data, load_train, load_test, num_classes=21) # 21 instead of 20 更新数据集中相关定义的“输出”枚举。在“weight”中添加其他项目,以匹配与“output”相同的数字。最后一个类 id 将表示“未知”类别(例如,22 个关键字的 21 个输出):
{
'name': 'KWS_20', # 20 keywords
'input': (128, 128),
'output': (0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21), # 20: 'hello', 21:'unknown'
'weight': (1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0.14), # one item added
'loader': KWS_20_get_datasets,
},
要平衡每个类的可变样本数的优化器,可以更改权重,与每个标签的样本数成反比。
3.4 更新模型 更新 ai8x-training/models/ai85net-kws20 初始化部分中的默认num_classes.py:
# num_classes = n keywords + 1 unknown
def __init__(
self,
num_classes=7,
num_channels=128,
dimensions=(128, 1),
fc_inputs=7,
bias=False,
**kwargs
):
3.5. 训练、量化、评估和综合 如前所述,按照一般过程进行训练、量化、评估和综合,以生成KAT C代码。此处会在预训练实现过程种详细体现。 3.6 更新 kws20_demo 演示应用程序 将 cnn.c、cnn.h、weights.h、sampledata.h 替换为 KAT C 代码中生成的代码。 用自己的类名更新 main.c,
const char keywords [NUM_OUTPUTS][10]= { "a110", "a119", "a120", "a551", "go", "stop", "Unknown" };
进行打印正确类名所需的任何其他更改 如上所述构建、刷写和运行代码
4. 预训练实现过程
4.1 环境搭建
如果想使用GPU加速神经网络训练,需要配置CUDA环境。官方仓库的requirements.txt文件中安装的pytorch版本为1.8.1+cu111,即依赖CUDA 11.1版本。 安装pyenv,通过pyenv来配置Python
curl -L https://github.com/pyenv/pyenv-installer/raw/master/bin/pyenv-installer | bash # NOTE: Verify contents of the script before running it!!
pyenv install 3.8.11
下载Github上的ai8x-training和ai8x-synthesis仓库
git clone --recursive https://github.com/MaximIntegratedAI/ai8x-training.git
git clone --recursive https://github.com/MaximIntegratedAI/ai8x-synthesis.git
创建虚拟环境
cd ai8x-training
pyenv local 3.8.11
python -m venv venv --prompt ai8x-training
source venv/bin/activate
pip3 install -U pip wheel setuptools
安装CUDA环境
pip3 install -r requirements.txt
检查CUDA硬件加速是否可用
(ai8x-training) $ nvidia-smi -q
...
Driver Version : 470.57.02
CUDA Version : 11.4
验证PyTorch是否识别CUDA
(ai8x-training) $ python check_cuda.py
System: linux
Python version: 3.8.11 (default, Jul 14 2021, 12:46:05) [GCC 9.3.0]
PyTorch version: 1.8.1+cu111
CUDA acceleration: available in PyTorch
4.2 预训练 此次设计用到kws关键词识别,因此预训练使用train_kws20_v3.sh来进行训练。
cd ai8x-training
source venv/bin/activate
scripts/train_kws20_v3.sh
图 4-1 训练过程
图 4-2 生成的训练结果 完成训练后开始量化,生成c代码
sudo chmod -R 777 ai8x-synthesis
cd ai8x-synthesis
source venv/bin/activate
python quantize.py trained/best.pth.tar trained/best-q.pth.tar --device MAX78000 -v "$@" //量化
scripts/gen_kws20_v3_max78000.sh //生成.C文件
图 4-3 量化完成生成的工程
将 cnn.c、cnn.h、weights.h、sampledata.h 替换为 KAT C 代码中生成的代码。
5. 实现结果展示
图 3-1 识别结果
图 3-2 TCP客户端接收到的信息
6. 项目复现过程
Max78000FTHR代码:存放在kws6.zip中
ESP32 devkit v1代码:
#include <WiFi.h> // 引入WiFi库
const char* ssid = "tcp_srv"; // 设置WiFi网络名称
const char* password = "12345678"; // 设置WiFi网络密码
WiFiServer server(80); // 创建一个WiFi服务器对象,监听端口80
void setup()
{
Serial.begin(115200);
Serial2.begin(115200); // 初始化串口通信,波特率为115200
delay(2000); // 延迟2秒钟
WiFi.begin(ssid, password); // 连接WiFi网络
while (WiFi.status() != WL_CONNECTED) // 等待WiFi连接成功
{
delay(5000);
Serial.println("Connecting to WiFi..."); // 打印连接中的提示信息
}
server.begin(); // 启动WiFi服务器
Serial.print("Server IP address: "); // 打印服务器IP地址
Serial.println(WiFi.localIP()); // 打印本地IP地址
}
void loop()
{
WiFiClient client = server.available(); // 等待客户端连接
if (client) // 如果有客户端连接
{
Serial.println("New client connected"); // 打印新客户端已连接的提示信息
while (client.connected()) // 当客户端连接状态保持
{
if (Serial2.available()) // 当串口有数据可用
{
//String response = Serial.readStringUntil('\n'); // 从串口读取数据,读取到换行符为止
int data = Serial2.read();
Serial.println(data);
client.print(data); // 将读取到的数据发送给客户端
client.println();
//delay(1000);
}
// 刷新客户端缓冲区
client.flush();
}
client.stop(); // 断开客户端连接
delay(100);
Serial.println("Client disconnected"); // 打印客户端已断开连接的提示信息
}
}
硬件连线图
7. 项目总结
历经三个月的磕磕绊绊对Max78000FTHR的学习算是入门吧,做的东西很基础,之后要持续学习,感觉这块板子的很多地方还值得我去挖掘。 这次做的设计还有许多改进之处,比如可以加一些控制指令,来控制打电话,而不是仅仅把数据传输到电脑上。 最后,感谢硬禾举办这样一个活动,给了我一次探索嵌入式AI的机会,这次感觉意犹未尽,希望还有下次比赛。