基于MAX78000FTHR的动物声音识别系统
一、项目简介
本项目是一个使用MAX78000为主控的AI自动识别项目,使用到的神经网络模型为卷积神经网络(CNN)。本项目将在Linux环境下使用python、pytorch等工具对不同的动物声音进行模型训练,然后使用官方提供的SDK生成与模型相应的C代码,其中CNN相关的代码可以移植到我们的项目代码中。本项目的最终应用平台为MAX78000FTHR开发板,我们将使用该开发板的板载麦克风并搭配另外的OLED屏幕对不同的动物声音进行识别与显示。
二、项目设计思路
1、观测对象
本项目的检测对象为9种不同动物的叫声,分别为:猫、牛、乌鸦、狗、青蛙、母鸡、猪、公鸡和羊。这些不同动物的声音经过数字化(板载麦克风采集)后为一串一维的长数组,然后将一这串数组等分为N(通道数)等份,这N份1维数组作为输入,经过AI模型计算后输出分类结果。
2、AI模型框架
图1 模型框架图
AI模型逻辑整体框图如上图图1所示,包含了正向传播和反向传播过程。其中隐含层包含了成千上万的参数,在深入学习AI之前隐含层在我眼里就是一个黑盒,完全不了解其工作方式,随着一点一点的学习才逐渐揭开其神秘面纱。正常应用时很好理解,就是已知输入层数值和隐含层数值,经过计算最终得出结果。而隐含层数值是需要经过训练才能得到的,而且每次训练得到数值还不一定相同。训练过程其实是已知输入层数值和输出结果求隐含层的数值。在训练时会给隐含层的所有参数一个随机的初始值,在已知输入值的情况下可以算出一个输出,再把这个输出与已知的输出比较,这个时候我们可以得到一个偏差。这个时候我们需要将这个偏差反向传播到隐含层来修改隐含层参数,这样就完成了一次训练,也叫学习。由于我们使用的是卷积神经网络,正向传播时还比较好计算,而反向传播时需要对每个参数求偏导,这个还是比较麻烦的。这个时候使用pytorch的好处就体现出来了,pytorch会自动完成求导,也就是说我们只用写好正向传播过程即可,反向传播可以交给pytorch。
三、素材搜集过程
1、原始素材搜集
本项目使用的音频数据来源于github上的开源数据集ESC-50,该数据集有动物声音、自然环境音、人类飞说话音等共计2000条音频数据,每条音频时长为5秒。本项目使用了ESC-50数据集中的部分动物声音数据。
图2 下载好的ESC-50数据集
下载好的ESC-50数据集如图2所示,每条音频都是根据类型序号命名的,这里我们可以用Python写个简单的代码将需要用到的动物声音文件复制出来,代码如下
animals = ["Dog","Rooster","Pig","Cow","Frog","Cat","Hen","Insects","Sheep","Crow"]
num = [0,0,0,0,0,0,0,0,0,0]
def get_animal_index(filename):
times = 0
target = ""
for c in filename:
if c == '.':
times=0
if times == 3:
target+=c
if c == '-':
times+=1
return int(target,10)
if __name__ =="__main__":
animalspath = "animals"
if not os.path.exists(animalspath):
os.mkdir(animalspath)
print("创建了animals文件夹")
for animal in animals:
animalpath = os.path.join(animalspath,animal)
if not os.path.exists(animalpath):
os.mkdir(animalpath)
print("创建了animals/%s文件夹"% animal)
audios = os.listdir("audio")
for file in audios:
index = get_animal_index(file)
if index<10:
source = os.path.join("audio",file)
animal = animals[index]
target = os.path.join("animals",animal,animal+str(num[index])+".wav")
num[index]+=1
try:
copyfile(source, target)
except IOError as e:
print("Unable to copy file. %s" % e)
exit(1)
except:
print("Unexpected error:", sys.exc_info())
exit(1)
直接运行上述代码就可以获取到本项目需要的动物声音文件,但这些音频文件仍不能直接用于训练,如果直接种ESC-50项目中的数据进行训练的话就会出现下图中的情况,模型无法收敛,最好的训练集准确度top1也只有46%,这个结果可以说是完全没法进行识别的。
图3 直接用原始数据训练的结果
2、音频处理
基于以上情况,我们需要对使用的音频数据进行一些处理,一方面是对音频进行剪切,将一个5秒的音频中有效的部分给单独剪切出来,另一方面是对这些数据进行一些筛选,将无效的数据去掉,让我们的模型更容易收敛(为了训练系统的鲁棒性,也需要一些不那么干净的数据)。于是我选择了使用Qt开发了一哥可以对音频进行可视化剪切的工具,整体思路大概是读取音频数据归一化到[1-,1]后使用QCharts进行显示,然后跟踪鼠标位置,鼠标左键确定剪切位置,右键取消操作,设计完成的工具界面如下图4所示。
图4 音频可视化剪切工具界面
手动剪切图片时需要注意每个片段内都需要包含有效音频,纯直线直接舍弃,每个音频片段保持在1秒左右即可。剪切玩的数据集就可以用于训练AI模型了。
四、AI模型训练
1、数据集处理
数据集的处理就时将剪切好的音频数据提取出来,根据配置可以加入一些随机干扰如一定范围内的随机频率变化、随机白噪音、随机时移等。再把提取出的音频数据归一化到[-1,1],然后分为一定长度的N等份。本项目中对音频处理的方式为读取音频文件中的43008个采样点(多的去掉,少的补0),每256个采样点为一段(一个通道),总共168个通道。所以本项目的AI模型网络输入形状为H*W*C:1*256*168。数据集处理主要代码如下
def __gen_datasets(self, exp_len=43008, row_len=256, overlap_ratio=0):
print('Generating dataset from raw data samples for the first time. ')
with warnings.catch_warnings():
warnings.simplefilter('error')
lst = sorted(os.listdir(self.raw_folder))
labels = [d for d in lst if os.path.isdir(os.path.join(self.raw_folder, d))
and d[0].isalpha()]
overlap = int(np.ceil(row_len * overlap_ratio))
num_rows = int(np.ceil(exp_len / (row_len - overlap)))
data_len = int((num_rows*row_len - (num_rows-1)*overlap))
print(f'data_len: {data_len}')
print('------------- Label Size ---------------')
for i, label in enumerate(labels):
record_list = os.listdir(os.path.join(self.raw_folder, label))
print(f'{label:8s}: \t{len(record_list)}')
print('------------------------------------------')
for i, label in enumerate(labels):
print(f'Processing the label: {label}. {i + 1} of {len(labels)}')
record_list = sorted(os.listdir(os.path.join(self.raw_folder, label)))
if not self.save_unquantized:
data_in = np.empty(((self.augmentation['aug_num'] + 1) * len(record_list),
row_len, num_rows), dtype=np.uint8)
else:
data_in = np.empty(((self.augmentation['aug_num'] + 1) * len(record_list),
row_len, num_rows), dtype=np.float32)
data_type = np.empty(((self.augmentation['aug_num'] + 1) * len(record_list), 1),
dtype=np.uint8)
data_class = np.full(((self.augmentation['aug_num'] + 1) * len(record_list), 1), i,
dtype=np.uint8)
time_s = time.time()
train_count = 0
test_count = 0
for r, record_name in enumerate(record_list):
if r % 1000 == 0:
print(f'\t{r + 1} of {len(record_list)}')
if hash(record_name) % 10 < 8:
d_typ = np.uint8(0) # train+val
train_count += 1
else:
d_typ = np.uint8(1) # test
test_count += 1
record_pth = os.path.join(self.raw_folder, label, record_name)
record, fs = librosa.load(record_pth, offset=0, sr=None)
audio_seq_list = self.augment_multiple(record, fs,self.augmentation['aug_num'])
for n_a, audio_seq in enumerate(audio_seq_list):
data_type[(self.augmentation['aug_num'] + 1) * r + n_a, 0] = d_typ
for n_r in range(num_rows):
start_idx = n_r*(row_len - overlap)
end_idx = start_idx + row_len
audio_chunk = audio_seq[start_idx:end_idx]
# pad zero if the length of the chunk is smaller than row_len
audio_chunk = np.pad(audio_chunk, [0, row_len-audio_chunk.size])
# store input data after quantization
data_idx = (self.augmentation['aug_num'] + 1) * r + n_a
if not self.save_unquantized:
data_in[data_idx, :, n_r] = \
ANIMALS.quantize_audio(audio_chunk,
num_bits=self.quantization['bits'],
compand=self.quantization['compand'],
mu=self.quantization['mu'])
else:
data_in[data_idx, :, n_r] = audio_chunk
dur = time.time() - time_s
print(f'Finished in {dur:.3f} seconds.')
print(data_in.shape)
time_s = time.time()
if i == 0:
data_in_all = data_in.copy()
data_class_all = data_class.copy()
data_type_all = data_type.copy()
else:
data_in_all = np.concatenate((data_in_all, data_in), axis=0)
data_class_all = np.concatenate((data_class_all, data_class), axis=0)
data_type_all = np.concatenate((data_type_all, data_type), axis=0)
dur = time.time() - time_s
print(f'Data concatenation finished in {dur:.3f} seconds.')
data_in_all = torch.from_numpy(data_in_all)
data_class_all = torch.from_numpy(data_class_all)
data_type_all = torch.from_numpy(data_type_all)
mfcc_dataset = (data_in_all, data_class_all, data_type_all)
torch.save(mfcc_dataset, os.path.join(self.processed_folder, self.data_file))
print('Dataset created.')
print(f'Training+Validation: {train_count}, Test: {test_count}')
2、CNN网络模型
图5 CNN模型框图
我的CNN模型框图如上图图5所示,其中绿色的为输入层(一串长度为43008的一维数组);黑色字体为隐含层的具体结构,共9层,其中前8层为卷积层,第9层为全连接层;黄色为中间的输出结果;红色框为最终输出结果。
图5中第2、4、6、7卷积层的pading为1,这将会在输入的一维数据左右两侧各补一列0,深度不变,同时卷积核的步长为1,这样是为了在经过卷积后的输出保持数据尺寸不变,输出深度与卷积核的数量相同。这四个卷积层的后面都会跟一个最大池化,这样做的目的是为了降低数据尺寸,也叫降采样。同时从图5 也可以看出所有卷积层使用的激活函数都是ReLU,ReLU是使用相对更广泛的激活函数,其他激活函数有Sigmoid、Tanh、Leaky ReLU等,各有优劣,本项目使用的是ReLU函数。全连接层使用的是pytorch提供的linear函数,使用时只需给linear函数提供输入尺寸和输出数量即可,用起来比较方便。完成的网络模型代码如下
class AI85ANIMALSNet(nn.Module):
def __init__(self,num_classes=10,num_channels=168, dimensions=(256, 1), bias=False, **kwargs):
super().__init__()
#神经元0.2概率不激活
self.drop = nn.Dropout(p=0.2)
self.voice_conv1 = ai8x.FusedConv1dReLU(num_channels, 100, 1, stride=1, padding=0, bias=bias, **kwargs)
self.voice_conv2 = ai8x.FusedConv1dReLU(100, 96, 3, stride=1, padding=0, bias=bias, **kwargs)
self.voice_conv3 = ai8x.FusedMaxPoolConv1dReLU(96, 64, 3, stride=1, padding=1, bias=bias, **kwargs)
self.voice_conv4 = ai8x.FusedConv1dReLU(64, 48, 3, stride=1, padding=0, bias=bias, **kwargs)
self.animals_conv1 = ai8x.FusedMaxPoolConv1dReLU(48, 64, 3, stride=1, padding=1, bias=bias, **kwargs)
self.animals_conv2 = ai8x.FusedConv1dReLU(64, 96, 3, stride=1, padding=0, bias=bias, **kwargs)
self.animals_conv3 = ai8x.FusedAvgPoolConv1dReLU(96, 100, 3, stride=1, padding=1, bias=bias, **kwargs)
self.animals_conv4 = ai8x.FusedMaxPoolConv1dReLU(100, 64, 6, stride=1, padding=1, bias=bias, **kwargs)
self.fc = ai8x.Linear(768, num_classes, bias=bias, wide=True, **kwargs)
def forward(self, x): # pylint: disable=arguments-differ
"""Forward prop"""
# Run CNN
x = self.voice_conv1(x)
x = self.voice_conv2(x)
x = self.drop(x)
x = self.voice_conv3(x)
x = self.voice_conv4(x)
x = self.drop(x)
x = self.animals_conv1(x)
x = self.animals_conv2(x)
x = self.drop(x)
x = self.animals_conv3(x)
x = self.animals_conv4(x)
x = x.view(x.size(0), -1)
x = self.fc(x)
return x
3、训练、评估、量化
完成了最难的前两步后剩下的就比较简单了,需要编写训练、评估、量化的bash脚本,这样就不用每次训练都要输出一长串的命令和参数了。这一步参考官方demo和readme文件可以很轻松的写出来,官方readme文档里也有详细的说明每个参数的作用和示例。写完这些就可以按部就班的开始训练、评估、量化,最后生成C代码然后移植到MAX78000开发板上即可。这套流程中最耗时的就是训练,不过由于我自己的数据集比较小,训练起来还是很快的。
4、MAX78000开发
想要在开发板上实现动物声音识别我们首先需要使用板载的数字麦克风(I2S驱动)采集到一段有效音频。这里我选择了使用DMA来传输I2S数据,这样可以提高CPU的使用率,并且我们可以对每段DMA数据进行均值计算,当均值大于一定值时(音量足够大)才开始真正采集数据,以实现自动音频判断采集。不过进行均值计算之前需要先对数据进行滤波处理,这里我们采用的是截止频率为100Hz的高通滤波器,主要是为了滤除直流分量和一些低频干扰,滤波前后的数据对比如下图6、图7所示。从这两图中可以明显看出滤波前有一个159左右的直流分量,经过滤波处理后就没有了。
图6 滤波前的数据发送到匿名上位机显示
图7 滤波后的数据发送到匿名上位机显示
最终声音采集代码如下所示
char mic_read_task(void)
{
static uint32_t index = 0;
static uint16_t data_index = 0;
static char flag_start=0;
int32_t sample=0;
int16_t temp=0;
uint32_t sum=0;
uint16_t avg=0;
if(i2s_flag==1){
i2s_flag = 0;
MXC_DMA_ReleaseChannel(0);
MXC_I2S_RXDMAConfig(i2s_rx_buffer, I2S_RX_BUFFER_SIZE * 4);
for(uint8_t i=0;i<I2S_RX_BUFFER_SIZE;i++){
sample = (int32_t)i2s_rx_buffer[i];
temp = sample >> 14;
sample = HPF(temp);//高通滤波器
if(index>9999){
if(sample>0)
sum += sample;
else
sum -= sample;
if(flag_start==1)
{
audio_data[data_index] = (uint8_t)((sample)*4 / 256);
//printf("audio sample %d : %d - %d\n",data_index,sample,audio_data[data_index]);
data_index++;
}
}
index++;
}
}
avg = sum/64;
//根据音频幅度大小自动判断开始识别
if(avg>350 && flag_start==0)flag_start=1;
if(data_index>43008){
index = 0;
data_index = 0;
flag_start = 0;
return 1;
}
else return 0;
}
采集好音频数据后就可以送入CNN入口进行识别了,主要实现代码如下所示
int main(void)
{
int digs, tens;
int16_t out_class = -1;
double probability = 0;
MXC_ICC_Enable(MXC_ICC0);
MXC_SYS_Clock_Select(MXC_SYS_CLOCK_IPO);
SystemCoreClockUpdate();
ContinuousTimer();
Microphone_Power(POWER_ON);
MXC_Delay(200000);
OLED_Init();
OLED_ShowStr(28,6,"PRESS K1!",Font8x16_Normal);
I2SInit();
PB_Init();
PB_RegisterCallback(0,key1_isr);//SW1
PB_RegisterCallback(1,key2_isr);//SW2
PB_IntEnable(0);
PB_IntEnable(1);
cnn_enable(MXC_S_GCR_PCLKDIV_CNNCLKSEL_PCLK, MXC_S_GCR_PCLKDIV_CNNCLKDIV_DIV1);
cnn_init(); // Bring state machine into consistent state
cnn_load_weights(); // Load kernels
cnn_load_bias(); // Not used in this network
cnn_configure(); // Configure state machine
uint32_t time_to_wait_ledflash = 0;
uint32_t time_to_wait_oled = 0;
uint8_t cnn_show_times = 0;
while (1) {
// if(keypressed){
// keypressed=0;
// micTest();
// }
//按下K1开始采集声音
if(keypressed){
change_oleed_stat(LISTENING);
if(mic_read_task()==1){
keypressed=0;
printf("audio data done!\n");
cnn_load_input(); // Load data input
cnn_start(); // Start CNN processing
SCB->SCR &= ~SCB_SCR_SLEEPDEEP_Msk; // SLEEPDEEP=0
while (cnn_time == 0)
__WFI(); // Wait for CNN
softmax_layer();
printf("Approximate inference time: %u us\n\n", cnn_time);
//cnn_disable(); // Shut down CNN clock, disable peripheral
printf("Classification results:\n");
for (uint8_t i = 0; i < CNN_NUM_OUTPUTS; i++) {
digs = (1000 * ml_softmax[i] + 0x4000) >> 15;
tens = digs % 10;
digs = digs / 10;
printf("[%7d] -> %s: %d.%d%%\n", ml_data[i], animals[i], digs, tens);
}
int ret = check_inference(ml_softmax, ml_data, &out_class, &probability);
if(ret==0)printf("unknow!!\n");
else printf("Detected animal: %s (%0.1f%%)\n", animals[out_class], probability);
change_oleed_stat(CNN_DONE);
if(ret==1)OLED_SHOW_CNN(out_class);
else{
OLED_ShowHz(32,2,12,FontHz32x32_Normal);
OLED_ShowHz(64,2,13,FontHz32x32_Normal);
}
}
}
//按下K2将采集到声音上传到匿名上位机,调试用
if(key2pressed)
{
key2pressed=0;
print_audio_data();
}
// if(try_to_wait(&time_to_wait_ledflash,10)==0)
// {
// LED_Toggle(LED1);
// //printf("sys_tme_100ms : %d\n", sys_tme_100ms);
// }
if(try_to_wait(&time_to_wait_oled,2)==0)
{
switch (oled_stat)
{
case WAIT_PRESS:
OLED_DrawBMP(40,0,48,6,(uint8_t*)BUTTON_GIF[gif_index++]);
if(gif_index>1)gif_index=0;
//gif_index!=gif_index;
break;
case LISTENING:
OLED_DrawBMP(32,0,64,8,(uint8_t*)MIC_GIF[gif_index++]);
if(gif_index>2)gif_index=0;
break;
case CNN_DONE:
cnn_show_times++;
if(cnn_show_times>10){
cnn_show_times=0;
change_oleed_stat(WAIT_PRESS);
}
break;
}
}
}
}
五、实现结果
成功识别后会分别显示对应的名称,分别如下图所示。
六、总结
经过着两个多月的摸索,总算是完成了这个AI小项目,再此期间感觉最难的部分就是数据集的处理与AI模型的构建上,涉及到一些pytorch的计算知识,这需要去查阅大量的资料,而网络上的资料又参差不齐,有时边写代码边搜索资料都把思路打断了。不过好在最后还是完成了此次比赛,本项目的识别率还是有待提高,个人认为未来想要提高主要还是需要提高数据集的质量与数量,训练样本上去了识别才能更准确。