项目背景
《王者荣耀》是腾讯的一款5V5团队公平竞技手游,国民MOBA手游大作!玩家(召唤师)可以从五类英雄中选择一类出战:上单、法师、射手、打野、辅助,召唤师开局可选择一个召唤师技能,另外局内多种装备增强英雄攻击力、防御、存活能力等。
在对局中,能否击败对方英雄很多时候取决于对方的保命技能,包括召唤师技能和部分装备带来的主动、被动技能(部分英雄的自带技能也有保命的作用,但是该类技能的冷却时间受铭文、装备以及英雄等级的影响,难以确定)。对局中,召唤师技能和装备技能的冷却时间固定的(如下表1)。但是一场游戏中,一个英雄可能出多个带有技能的装备,而五个英雄的技能情况则更为复杂。因此,在激烈的对战中,靠记忆记住对手的保命技能的冷却时间是比较难的。
表1:英雄部分保命技能冷却时间表
名称 | CD(秒) | 名称 | CD(秒) |
闪现 | 120 | 名刀 | 120 |
净化 | 120 | 金身 | 90 |
疾跑 | 90 | 复活甲 | 150 |
治疗 | 120 | 血怒 | 60 |
狂暴 | 60 | 冰霜冲击 | 60 |
MAX78000带有语音传感单元,可以使用该资源采集用户传入的语音数据,经过CNN加速器处理识别,可以实现记录一场对局中敌方英雄的保命技能的CD时间。
项目细节
- 关于技能冷却(CD)时间:技能在释放后,一定时间内出于无法再次释放该技能,这段时间称为冷却时间。在看到对方使用技能后,可以根据技能的冷却时间推测技能下次准备就绪的时间点。
- 关键字:英雄位置类5个、技能类16个、控制类三个(控制记录的状态);实际游戏中存在位置重叠或者英雄不合理(选了两个同一位置)的情况,在项目设计中,这种情况需要使用者额外记忆。
- 检测环境:实际游戏中,有音效以及和队友语音的情况,在项目设计中,默认是没有这些背景音的(模拟这些背景音难度太大,而且复杂情况下识别关键字挑战性太高)
实物展示
上分助手主要由MAX78000羽毛板、TFT屏、面包板和若干导线组成(手机支架是我前期想要做图像识别准备的,两块面包板是贴一起的。导致最后做语音也不得不用支架)
项目设计思路
本项目的技术重点可以分为两大部分:模型训练-量化-合成; AI模型部署&硬件驱动。第一部分神经网络训练涉及的问题:
1、合适的神经网络模型:使用官方的KWS_20 model;demo模型是对1秒英文关键字的检测,中文和英文发音略有不同,因此如demo模型不佳则可能需要调整网络结构,学习策略等等
2、样本数量以及质量合适的数据集:因为关键字是王者荣耀相关的中文关键字,所以需要自己采集,采集思路以及后续调整在下面的搜集素材思路章节和硬件部署及验证章节
3、量化算法及量化工具、合成c代码工具:官方提供的quantize.py、ai8xize.py
上分助手的主要功能包括:(初步设想)
- 语音关键字识别:因为是具体的MOBA对战场景,使用该场景中的常见术语作为关键字更有利于使用者记录。因为是多关键字检测任务,按照官网中KWS_demo的建议,使用简单状态机加上关键字识别来实现。
- 较为精确的计时功能:上分助手要能够以秒级的精确度监控时间变化,用以判断技能CD时间是否结束;因此,需要一个定时器;此外,使用者陆续说出一条完整记录所需关键字也需要时间(至少需要4s,最多并不清楚,这个时间也需要一个定时器)
- 技能CD展示和提醒:不同关键字对应的冷却时间的变化展示,以及在技能CD接近结束时进行提醒(初步设想是设置不同字体的显示颜色)。
模型训练
搜集素材思路
1、数量:每个关键字至少几百个sample
2、搜集途径:python文本转语音库(pyttsx3、)、官网提供的python脚本文件(长时间自动多次采集+自动根据能量划分)
3、数据集存储结构:以新关键字的标签名为文件夹名字创建新的数据文件夹
Pyttsx3+convert_segment_wav.py:
设置不同语速、音量,并保存(每类关键字生成36个sample),convert_segment_wav主要是保证样本的采集频率规范:
- 修改注册表中的默认发音人(主要是添加一个男声)
修改方法:https://blog.csdn.net/weixin_51461611/article/details/127858621
- 不同参数生成多个sample 代码在路径:(主要是对音量修改,模拟不同位置;(但是经过py重采样,声音似乎变得再次一样?但是通过RealtimeAudio.py将不同音量的原始数据重采样后的wav转化为头文件,发现是有区别的。因此模拟不同位置的想法应该是有效的))
E:\MaximSDK\Examples\MAX78000\CNN\kws20_demo\Utility\get_text-to-speech.py
官网Python脚本:VoiceRecoder+convert_segment_wav 自己的语音数据
- VoiceRecoder:修改文件存储位置及文件名(要保证类序号和该文件名排序相同)、修改LENGTH参数采集多个关键字
- convert_segment_wav:自动划分关键字,1秒,重采样频率至16kHZ
预训练实现过程
更新data loader
- 修改ai8x-training/datasets/kws20.py中的class_dict,将类序号按照文件夹增序排列
- 修改KWS_get_datasets函数:该函数读取的是dataset列表 因此修改该列表(不需要排序);修改KWS_20_get_datasets函数返回中的类的数量
- 修改KWS_get_datasets函数的dataset的选择,num_class的范围和上一步传入参数相符
- 修改ai8x-training/models/ai85net-kws20.py的构造函数中的num_classes
数据集更换(自定义关键字需要修改)
- 动态加载数据集过程:
动态加载datasets目录下所有py文件中的类(ds),并将类中的datasets属性收集起来;
加载命令行参数,选择数据集,读取对应数据集的dataset中的’loader’字段,获得数据集加载函数(KWS_20_get_datasets);
根据参数开始加载数据集(需要修改该过程);(进入函数后随机进入datasets_fn,即KWS_20_get_datasets;该函数调用KWS_get_datasets函数)
- 上一步中已经对py中的data_loader进行了相应修改,后面需要修改KWS_get_datasets函数,关掉自动下载数据集,并替换成自己的数据集。__download_and_extract_archive()函数需要filename变量来判断是否已存在下载完成压缩包;这里修改filename为自己的原始数据集的压缩文件名;
训练-量化-合成
训练
1)可视化训练过程:传入参数—tensorboard,使能tensorboard;并在http://localhost:6006/地址观测
2)最佳epochs:观测模型是否能收敛以及在何处收敛
初始epochs设置为20,50,100,200,验证损失变化如下:在第15个epoch处,验证损失降到0.1之下,随后开始震荡;(checkpoint检查vTop1,但vTop1不一定是最优标准)
epochs=20 vLoss = 0.08 best Top1出现在第19个epoch test Top1=95.632
epochs=50 vLoss = 0.05 best Top1出现在第31个epoch test Top1=84.253
epochs=100 vLoss = 0.06 best Top1出现在第93个epoch test Top1=97.701
epochs=200 vLoss = 0.009 best Top1出现在第157个epoch test Top1=98.851
3)最终选择文件为../ai8x-training/logs/2023.11.30-195108/qat_best.pth.tar,训练epoch为157,测试的Top1准确率达到98.851
量化
1)在ai8x-synthesis环境下运行指令:
python quantize.py ../ai8x-training/logs/2023.11.30-195108/qat_best.pth.tar ../ai8x-training/logs/2023.11.30-195108/qat_best-q.pth.tar --device MAX78000 -v "$@"
2)量化后验证:(在get_dataloader函数中只返回了test samples)
python train.py --model ai85kws20netv3 --dataset KWS_20 --confusion --evaluate --exp-load-weights-from ./logs/2023.11.30-195108/qat_best-q.pth.tar -8 --device MAX78000 "$@"
量化后--evaluate准确率:==> Top1: 98.736 Top5: 100.000 Loss: 0.091
合成(Network Loder)
需要三个输入:quantized checkpoint file、YAML description of specific network、.npy file with sample input data
- .npy file:在evaluate量化后模型精度时,添加参数—save-sample 1,保存代码中产生的test sample(1为想要保存的样本的下标);.npy文件生成在~/MAX78000/ai8x-training下
- YAML:(以硬件为中心的方式描述模型,pytorch生成的checkpoint文件保存的只有weights;另外,保存模型和权重还不足以生成运行在硬件上的C代码,还需要硬件资源调度数据)
https://github.com/MaximIntegratedAI/ai8x-synthesis#yaml-network-description
https://github.com/MaximIntegratedAI/MaximAI_Documentation/blob/master/Guides/YAML%20Quickstart.md
YAML参数:
1、输入数据格式data_format:C、H、W的顺序
2、该层使能的处理器的个数processor,它等于该层的输入通道数(每个processor处理一个通道):每四个处理器共享一个data memory,因此启用的processor个数是4的倍数;当输入通道数大于使能的processor数量x时,采用muiti-pass(x个processor分别处理y个通道);所以x*y>输入通道数
使用网络的输入为128通道,因此输入层的processor=0xffffffffffffffff;(共64位,每位表示一个processor使能)
Note:连续的层尽量使用内存实例不重叠的processor(使用重叠的processor时,下一层要等待上一层计算完成才可以进行计算);
data format为CHW时,必须使用非重叠的processor;
全连接层(MLP)的processor数量是flattening之后的神经元个数
3、是否展平flatten:MLP层设置为true
4、out_offset
python ai8xize.py --test-dir ~/MAX78000/MaximSDK/Examples/MAX78000/CNN/myself --sample-input ~/MAX78000/ai8x-training/logs/2023.11.30-195108/sample_kws_20.npy --prefix myself --checkpoint-file ~/MAX78000/ai8x-training/logs/2023.11.30-195108/qat_best-q.pth.tar --config-file ./networks/kws20-v3-hwc.yaml --softmax --verbose --device MAX78000 "$@"
复制生成的项目中的文件到KWS20demo中验证
初步的硬件部署及验证
初步c代码都是部署在SDK的E:\MaximSDK\Examples\MAX78000\CNN\KWS_20demo中,使用合成阶段生成的项目中的CNN相关文件替换KWS_20demo中的对应文件(cnn.c、cnn.h、weights.h);
第一轮数据采集方法:(识别精度糟糕)
- 数据采集方式:1.文本转语音库(可以设置不同音量和语速,并且有两个音色不同的语音输出);2.使用E:\MaximSDK\Examples\MAX78000\CNN\KWS_20demo\Utility\下的VoiceRecoder.py和convert_segment_wav.py采集多次自己的录音;
- 数量:每类关键字的样本数量在130-150
- 验证效果:很差,一个关键字读十次,可能才正确检测一次,有些关键字甚至检测不出来(非录制音频环境);回到录制音频的环境中验证,结果稍微好点(十次可能对个3、4次)。
- 问题(录制音频的环境下多次重复识别后发现):部分关键字容易混淆(如‘上单’容易识别成‘名刀’,‘辅助’和‘法师’互相混淆);距离、音调(‘射手’的音调要依次为三、二准确率才高一点)、重读(大部分关键词要在第二个字上重读)、快慢(很难把握)都影响检测结果,很难找到录制时的状态,因此效果整体比较玄学
- 原因分析:因为要连续读出关键字,读多了开始有点‘走形’,并且录制过程中和笔记本的位置没变化,自己发音的音量、音调没有变化,导致生成的样本较为特殊。训练样本缺乏多样性,导致训练的模型的实际泛化性能很差。
解决办法:增强数据样本多样性
鉴于之前连续采集关键字出现的种种可能问题,我在该数据基础上补充单次采集的关键字样本;
- 方法:在SDK的KWS_20demo中,可以通过宏定义设置MAX78000的工作模式:其中可以有两种模式可以通过MAX78000的Mic录制检测的关键字(单次关键字采集)
导入SD卡模式:可以将检测到的关键字音频(要检测到)导出成文件保存至SD卡,并且低可信度的关键字音频文件会以_L后缀提示。很方便,但是不保存检测为‘Unknown’类的关键字音频(当然,如果修改该SDK中的保存到SD卡的条件,应该也能保存Unknown类)
发送到串口模式:对于我的问题而言,MAX78000判断为Unknown的语音样本比认得出的语音样本更有价值(保存这些样本可以增强多样性)。当然,要保存对应的类信息。
而Sending Sound Snippets to serial模式,可以将检测样本结果和检测样本的音频数据转化为二进制传输给串口,并且检测为关键字类时,还能写入wav文件。
稍微修改capture_serial_bin.py就可以将采集的音频(包括 ‘Unknown’类)保存到指定目录;
主要修改函数为:增加一个从串口数据中判断关键字类型并返回的函数get_classes ;Capture_Serial根据判断的关键字类型是否为要采集的关键字,将串口数据转化为对应后缀名的文件保存;
- 可行性分析:
原方法:电脑Mic采集===>划分成单个wav文件
现方法:MAX78000 Mic采集===>串口发送二进制===>二进制转化为wav文件
两种方法转化为wav文件使用的是同一个库的同一个方法(sound.write),所以生成的音频的参数一致(16KHz,16bit深度);
区别:前者输出的wav数据的输入是打开wav数据读取的(convert_segment_wav.py);而后者是从串口的输出电平转化成二进制再转化为wav文件;(学识有限,不确定第二种方式是否会丢失信息,以及两种方法之间的差距有多大)(只能看实践效果,好在是不错的)
- 采集数据的可靠性:
出发点是MAX78000的mic采集的数据更接近每次检测的真实数据,然而不确定转化为串口数据时丢失了多少信息;(所以如果用SD卡的存储模式或许更合理,后面有机会可以尝试一下)
- 采集具体情况:多个位置(移动羽毛板的位置)、注意音调、重读等规范,采集样本量大概在150左右,其中三分之二是检测为‘Unknown’类的样本(整个采集过程中,总是下意识的去尝试什么样的读法是MAX78000能识别出来的,所以最后有些错觉,不是我在准备数据训练MAX78000,反而是我在训练我自己!!!∑(゚Д゚ノ)ノ)。
第二轮模型训练-量化-合成及硬件部署
数据集改动
如上一节解决办法所言,在原先的数据集基础上,丰富样本量
训练-量化-合成
- 训练损失:在接近180 epoch才开始出现收敛趋势
验证损失:在50epoch出现了疑似过拟合现象,但是这附近的验证损失(0.06)应该依旧大于训练损失(0.04),且训练损失震荡严重,所以模型大概率处于继续训练的过程。
epochs=200 vLoss = 0.0017 best Top1出现在第194个epoch test Top1=99.291最终选择文件为../ai8x-training/logs/2023.12.03-205256/ qat_best.pth.tar - 量化后验证:
相较于量化前下降了一个百分点,且这个准确率略低于第一轮数据采集实验的量化后验证精度(但是问题不大,有可能是因为验证样本更多样性了,模型泛化难度增大)
合成操作类似上面,修改文件名即可
第二轮部署及验证(与第一轮比较结果)在capture_serial_bin.py基础上创建test_onai85.py测试每个关键字检测需要读几遍:结果:数字代表每个关键字要读几遍才能正确检测;
从统计的结果可以看出,距离对第一轮检测结果影响很大(自己第一次部署完并测试就是近距离测试,结果很差并且混乱)。近距离下,只有13个关键字能在3次以内正确检测,并且有6个关键字10次以内都无法正确检测;中等距离下部分效果有略好,但达不到实际应用要求;远距离表现更差第二轮表现:只有在距离较远时才出现较差的效果,中等距离和近距离表现堪称完美
分析:(第一轮数据采集存在严重失误)官网的自定义关键字分类应用中其实有对数据集采集的要求:多次(样本量)、不同语调、不同语速、不同人;其实应该还包括不同音量(但官网提到的增强音量的speech_ptch_change.py并没有找到,所以没办法通过代码增强音量)
总的来说,第一次的数据采集效果并不好,数据样本量、多样性都不太充足(我每个关键词录40-50次,中间偶尔想起会变变音量以及自己的位置,但是大部分时间还是机械性的读。而且部分时候读的多了,反而感觉自己读的词很陌生,不知道该如何正确发音了)第二轮录音是单次录的,每次之间隔得有一定空闲。并且有意变化位置、音调(重读)、发音准确性,因此数据集效果表现更好。
AI模型部署&硬件驱动
模型部署合成阶段生成的C代码完成了CNN网络在MAX78000上的硬件匹配,调用提供的初始化、加载权重以及配置函数(cnn_init,cnn_load_weights、cnn_configure),便可以开始推理;
从cnn.h中加载kernel到加速器的weights内存中。之后调用cnn_load_data将IIS采集的数据从pAI85Buffer加载到data内存实例中,经过卷积运算,输出特征层层传递下去,在最后一层输出可分辨的高维特征;调用softmax_q17p14_q15函数将推理得到的高级特征进行softmax归一化,得到各类别概率输出。check_inference函数接受softmax输出,判断检测到的关键字类别;在得到关键字类别后,送入多关键字状态机进行对应处理;
硬件驱动整体逻辑
在SDK的Example/CNN/kws20_demo的基础上,完成所需要的硬件功能。从I2S缓存区中读取到合适数据后,开始进行CNN推理以及Softmax归一化,得到键字并送入状态机。此外,使能了TMR0、TMR1硬件作为秒级定时器。TMR0用于每隔5s更新一次记录链表中的所有技能CD,并在技能CD小于10s时显示不同颜色;TMR1用来计时当前正在采集的一条记录的用时,该次采集的技能的CD时间应该等于原始CD时间减去这个用时。此外,在“结束”关键字被检测到后,表示当前一次采集完成,相关标志位进行复位;
技能信息的存储结构
- 在该多关键词检查任务中,需要两个链表记录信息。List_current_word,记录当前正在采集的技能信息;List_record已记录的技能信息;具体的成员变量如下:
struct _current_word{ //记录当前回合依次接入的关键字
int class; //关键字的类别
char *word; //关键字名称
struct _current_word *next;
} *List_current_word;
struct Record{ //记录英雄、技能的状态链
char *role_word; //英雄名称
struct _skill_word{ //当前英雄的技能链,包括技能名称和CD时间
char *skill;
int time;
struct _skill_word *next;
} *list_skill_word;
struct Record *next;
} *List_record;
- 状态机:判断关键字类别
- Case 1:开始,置位IS_FLAG=True;开始定时器TMR1,记录使用者第一次观测到对方技能释放,到完成一条完整关键字的记录的时间。
- Case 2:位置类,当IS_FLAG==True时,调用func_append2word_list函数记录该关键字到当前关键词链表List_current_word;位置类计数标志num_role++;
- Case 3:技能类,当IS_FLAG==True时,调用func_append2word_list函数记录该关键字到当前关键词链表List_current_word;位置类计数标志num_skill++;
- Case 4:撤回,当IS_FLAG==True时,调用func_withdraw_last_word从List_current_word链表中撤回上一个关键字;对应计数标志位减一
- Case 5:结束,当IS_FLAG==True时,判断记录是否完整包括英雄和技能类关键字(num_role!=0 && num_skill!=0),在条件成立后置位IS_END;并且清除IS_START
一次完整的技能信息采集流程如下框图所示,在初始化相关GPIO、时钟、定时器、TFT、IIS、CNN等模块后,开始进行状态机处理
定时器中断处理函数
- 使用了两个定时器TMR0和TMR1,都是初始化为32.768kHz的时钟源(误差极小);TMR1用来计时一条完整记录的用时;TMR0用来定期更新技能CD(每5秒更新一次List_record,技能CD减到10s以内,就改变对应技能名称的颜色)ContinuousTimerHandler_0:更新技能CD时间,并打印新的状态到TFT屏ContinuousTimerHandler:判断IS_END,当其为False时,表示记录还未结束,记录花费的时间(通过TMR1定时器终端函数的触发次数)。IS_END==True,更新完整的记录List_current_word到List_record,并根据TMR1的终端周期period和触发次数计数变量conut_for_one_entire_record计算出技能此时的CD时间;
TFT屏显示中文
- 1)SDK提供的TFT屏驱动是显示ASCII的128个字符,字体和字模大小如下:MS_Reference_Sans_Serif font with 16*16 matrix for Adafruit 2.4" TFT33, 16, 16, 2, // number of bytes per character; horizontal size in pixels; vertical size in pixels; number of bytes per vertical line 字符取模大小为16*16,用字节表示,则需要16*16/8=32个,该字符字库增加了一个字节表示该字符的字节数,因此每个字符是33个字节表示。每竖行用2字节表示,打印到TFT屏显示时,通过依次读取2字节并设置对应像素的颜色即可完成字符的显示输出。
- 2)制作中文字库,把用到的中文汉字取模并保存到一个.c文件中
文字取模软件使用: - Step1:在参数设置中的文字输入字体选择中设置字体和大小:文字字体可以自选,大小为小四号(保证取模大小为16*16)
- Step 2:在参数设置的其他选项中:设置取模方式为横向取模,并勾选字节倒序
- Step3:在文字输入去输入要取模的汉字,ctrl+enter取模;
- Step4:点击左侧取模方式中的C51格式,就得到的汉字的字模
- 3)创建GB16X16.c文件,定义const unsigned char GB16x16[]保存这些字模,在数组开头定义字符字节大小为32,横竖字节各16,每竖读取2字节;因为中文字库的字模大小为32,和ASCII不同,并且关键字显示至少两个中文;根据这些不同,在SDK函数的基础上新建部分函数(以后缀_GB表示修改的函数):
- TFT_Print_GB,根据传入的位置坐标和关键字类索引,调用MXC_TFT_PrintFont_GB。在该函数判断关键字的中文字数,多次调用tft_gb_cahracter打印中文字符;
- tft_gb_cahracter,该函数需要根据新的字库文件设置字模读取的起始地址,以及读取字节时的位移地址;主要修改处为sym = &g_font[out_class*offset + 4]; //关键字的类别下标*偏移量+4个字节的字库参数z = sym[bpl * j + ((i & 0xF8) >> 3)];//bpl为2表示每竖两个字节,先读取每竖的第一个字节,显示完该8位bit后,字节增加1显示第二个字节;
效果展示:
预期实验现象:在TFT屏上显示已记录的英雄的技能信息,包括技能名称和技能CD;每个英雄可能有多个技能信息需要记录展示;CD随着时间变化逐渐减少,当所剩时间小于10s时,使用蓝色突出;初始化状态:简单提示如何使用
关键词检测:未知、三个控制类关键字:
记录展示,多个英雄,多个技能,以及CD时间显示:错误及solution
Solution:修改编码格式
2)启用tensorbord时遇到module 'PIL.Image' has no attribute 'ANTIALIAS'报错:在validate函数中需要打印一张混淆矩阵图片,但是遇到该报错问题Solution:Pillow版本太高,将其版本降到9.5.0error argument --enable-tensorboard/--tensorboard: not allowed with argument --no-tensorboardSolution:--enable-tensorboard/--tensorboard和-no-tensorboard为互斥组参数,不能同时出现;且两组都是只需指定该参数,无需传值;3)(WSL在运行时)pycharm闪退:猜测是虚拟内存不足,pycharm在debug和run的时候需要大量的虚拟内存,同样的WSL也需要占用部分虚拟内存;初始的虚拟内存设置的在c盘,且大小为系统自定义(实际好像在3.5G左右);Solution:第一步编辑注册表将虚拟内存唯一c盘取消,允许系统存在多个盘的虚拟内存,在D、E盘也设置虚拟内存,分别为该盘下的软件提供虚拟内存空间(该方法不确定,找不到资料,仅为猜测) - 解压自己的数据集压缩包时(windows下的zip格式文件),中文的目录、样本名解压出乱码:
- WSL平台中修改命令行参数脚本遇到报错: