第一次写博客,新手上路,请多关照哈。
闲话就不多说了。这次做数电大作业,笔者主要参考了下面三篇博文(顺便感谢一下这位创作者)。但代码也不是照搬就能用的,笔者对其进行了改进与创新。详情见后文。
基于MATLAB的人脸检测
基于FPGA的人脸检测(1)
基于FPGA的人脸检测(2)
本次实验所用到的软硬件环境如下:
1、VIVADO2017.4软件环境
2、EGO1开发板,板载 Xilinx Artix-7 系列 XC7A35T-1CSG324C FPGA
(FPGA是什么?Field Programmable Gate Array,“现场可编程逻辑门阵列”,说人话,即可以编程的硬件,用于编程的语言叫硬件描述语言(HDL),verilog语言就是一种HDL,就是本次实验使用的语言)
3、MATLABR2020b(用于验证实验结果的正确性)、pythonIDE(用于将图片转为位图数据以及将FPGA回传的位图数据转为图片)、DevC++(用于验证算法的可行性)、VSCode(用于写代码,vivado写代码的界面太不好用了…)
4、串口调试助手(用于上位机的数据发送与接收,与开发板采用UART串口通信)
这次试验干了些什么呢?简要概括,就是通过上位机向FPGA发送一张图片的信息,然后FPGA识别出图片里有多少个后将信息显示在EGO1开发板的七段数码管上,并将缩略图显示在VGA上,等图片处理好后,可以将处理后的黑白图片回传给上位机。
工欲善其事,必先利其器。Matlab是一个强大的工具,据说除了生孩子啥都能干。强大的库函数是它的力量源泉。
clc;
clear all;
close;
image = imread('near1024x768.bmp');
%------------------------原图----------------------------
subplot(2,2,1)
image_red = image(:,:,1);
image_green = image(:,:,2);
image_blue = image(:,:,3);
[ROW,COL] = size(image_red);
figure(1)
imshow(image);
title('原图','Color','black','FontSize',14);
%------------------------肤色检测----------------------------
YCbCr = rgb2ycbcr(image); %将RGB格式转为YCbCr格式
Y = YCbCr(:,:,1);
Cb = YCbCr(:,:,2);
Cr = YCbCr(:,:,3);
pic_gray = zeros(ROW,COL);
for i = 1:ROW
for j = 1:COL
if(Cb(i,j) > 77 && Cb(i,j) < 127 && Cr(i,j) > 133 && Cr(i,j) < 173)
pic_gray(i,j) = 255;
else
pic_gray(i,j) = 0;
end
end
end
subplot(2,2,2)
imshow(pic_gray);
title('肤色检测图','Color','black','FontSize',14);
%------------------------腐蚀----------------------------
subplot(2,2,3)
erodesize = 41;
se = strel('square',erodesize);
erode = imerode(pic_gray,se);
imshow(erode);
strerode = sprintf('腐蚀(卷积核大小:%d)\n',erodesize);
title(strerode,'Color','black','FontSize',14);
%------------------------膨胀----------------------------
subplot(2,2,4)
imdilatesize = 51;
se1 = strel('square',imdilatesize);
image_1 = imdilate(erode,se1);
imshow(image_1);
%------------------计数连通区域--------------------
image_label = im2bw(image_1);
[labeled,number]=bwlabel(image_label,4);
strnumber = sprintf('人数:%d',number);
%-----------------添加最后的标题---------------------
strimdilate = sprintf('再膨胀(卷积核大小:%d)\n%s',imdilatesize,strnumber);
title(strimdilate,'Color','black','FontSize',14 );
这次实验的最关键最关键的部分在于,如何将Matlab里的三个库函数移植到FPGA上。这三个库函数分别是:
以下是关于这三个函数的详细介绍,笔者就不赘述了。
图像腐蚀
Matlab图像形态学处理
bwlabel函数(二值图像中元素标记)
单看代码还是很抽象,放两张Matlab生成的实验结果,就能很快说明很多问题了。
实验的原理并不复杂。首先将原图从RGB格式转为YCbCr格式,因为YCbCr格式中Y是亮度信息、Cr表示红色信息、Cb表示绿色信息。转换成此格式的原因是为了将亮度相关的信息取消掉,进而利用红色分量和绿色分量判断出哪里是肤色。经过数据分析,肤色的分量处于下面的范围:
Cb(i,j) > 77 && Cb(i,j) < 127 && Cr(i,j) > 133 && Cr(i,j) < 173
其次,对图像进行腐蚀运算。
erodesize = 41;
se = strel('square',erodesize); %生成一个41x41的正方形
erode = imerode(pic_gray,se);
注意到imerode函数有两个参数,一个是灰度图,另一个就是用来运算的卷积核。这里的卷积核可以理解为一个小正方形,小正方形的大小是一个重要的参数(也就是上面的erodesize),决定了腐蚀后的图像形态。卷积核大,则腐蚀后剩余的白色区域小、少;卷积核小,则腐蚀后剩余的白色区域大、多。用到腐蚀函数是因为,我们要去除图像中的噪声干扰。
那为什么之后还要用到膨胀函数呢?这是因为,膨胀函数可以填补腐蚀后图像白色区域之间的缝隙,减少误算。举个例子,第二张图里由于存在黑色的眼镜,人脸经过腐蚀后并不是完整的一块白色,而是分成了五块但经过膨胀后,又连为一体了。
经过多次实验,笔者发现膨胀核略大于腐蚀核,图像的处理效果比较好,结果较为准确。
用Matlab验证了实验的可行性,只是万里长征走完了第一步。须知硬件描述语言verilog和Matlab语言是不一样的,是没有得天独厚的那么多库函数的,只能将其算法一步步的移植到硬件上。好在我们只需要移植三个函数,而前两个,imerode和imdilate的原理几乎相同,看样子是十分简单的。可实际操作起来就是另一回事情了。
笔者很快来到了实验的第一个瓶颈:内存问题。笔者立志像那篇博客一样,处理1024x768的分辨率的图片。但不算不知道,一算吓一跳。我们平时使用的图片通常是24位RGB图,也就是一个像素点用24位二进制数表示,这样的话,一张1024x768的位图大小是1024x768x24 = 18874368bit = 18432kbit = 18Mbit.
(这里要啰嗦一句Mb和MB的区别:由于计算机上的存储是以字节(Byte)为单位的,1Byte = 8Bit,所以这个18Mb的图片在电脑上显示大小的是18/8 = 2.25MB )
而我们的FPGA有多大的内存呢?它只有2Mb的内存,是的,只有2Mb.即便加上板载的一块2Mb的SRAM,笔者的手头也仅仅有4Mb,和18Mb的图片根本不在一个数量级。图片连存都存不下,又何谈图片处理呢?
那篇博客是如何处理的呢?仔细查看了下介绍,发现它用到了DDR3内存条,而查阅资料才知道,DDR3的内存空间动辄1GB!此外,用FPGA控制DDR3的存取需要用verilog语言编写复杂的DDR3控制模块,对我这样的新手来说是史诗级难度。巧妇难为无米之炊!所以只能望洋兴叹、坐以待毙了?
我还没有学过C++语言,但有一天偶尔看到它的iostream,启发了我。须知,我们的数据是以数据流的形式进入FPGA的,因此,并没有必要使用那么多的内存空间。打个比方,好比我们要检测长江某个节点一年的水质变化情况,那么我们在它的那个节点检测一年流过的水流就可以了,并没有必要像核废水那样,把长江一年的巨量的流量都储存到罐子里,再一个个去检验。也就是说,数据像水流一样,流过,留下有用的信息后就没有用了。根据这个思路,具体到本次实验,对我们来说有用的信息是什么呢?
1.经过处理后的图像数据。由于彩色图像经过处理后,转化为黑白二值图像,也就是说一个像素点只需要1个bit就可以表示。需要占用1024*768 bit = 768kb的内存。
2.彩色缩略图。由于笔者希望将结果展示在VGA上,因此少不了原图的展示,上面已经提到,我们没有那么多空间储存1024x768的24位RGB图。考虑到VGA的分辨率是640x480、
开发板只能输出12位RGB的图以及内存的限制,笔者最终决定将1024x768的24位RGB图转化为256x192的12位RGB缩略图存储到内存里,需要占用 25619212/1024 = 576kb的内存。
3.检测的人数结果。这个占用的内存就是一个小小的寄存器,不需要格外考虑它的内存问题。
综上,我们至少需要768+576 = 1344kb的内存用来存储检测结果,小于FPGA自带的1800kbBRAM空间。内存问题就这样解决了。
接下来就要考虑如何设计模块了。为了便于说明问题,先上几张最后的成果图。
这是top_0_demo的电路原理图
把top0打开的电路原理图
在核心的几个模块处放大
top模块:数据流(rx_data)以比特流(1位)的形式从串口进入,先通过uart_top模块将其变为字节流(8位)的形式,再通过top模块里的状态机(FSM)实现移位寄存器的功能,将字节流转化为24位的数据流,进行下一步处理;FSM同时对数据进行取样,每16个像素取一次样,取每个像素各8位RGB分量的高4位,一共12位存入BRAM,产生256x192的12位RGB位图。上面那眼花缭乱的一堆线路大部分都是FSM。
注意到上面模块里有输入的pi_flag和输出的po_flag。在数据流经的各个模块的过程中,flag很重要。这个flag可以理解为采样的时间。上游的模块处理好数据后,就给下游一个flag信号脉冲,从而使数据处理有条不紊。
想清楚这些问题后,就可以开始编写myerode模块了。若卷积核的大小为size,则myerode里使用一块位宽为size,深度为1024的内存,用于储存流过的信息。这是因为在腐蚀图像的时候,原图与腐蚀后的图像构成映射关系。腐蚀后的图像上某一个位置的点的数值,取决于这个原图上这个对应位置周围的sizexsize个点的数值,是多对一的关系。因此,myerode模块所做的,就是把输入的这幅拉成1维的细丝图像数据流,先在内存里恢复为2维,再将其拉成1维输出到下一个模块。768x1024的内存肯定能干这个问题,但我们希望使用的内存越小越好,sizex1024的内存就够了。假设下面这张图是输入的数据,那么数据流是它从第一行向右到头、再从第二行向右逐个扫描输出的。数据流先填满空的size*1024的内存后,每个地址的size位数据开始进行移位寄存器的操作,也就是说新的数据会把最上面的最旧的数据挤出去。整个过程等同于图像从下往上运动,逐步用自己的不同位置填充这块内存。运动是相对的,这个过程也相当于用这块内存对图像进行了由上到下的扫描。
还有一个细节问题:myerode输出数据的时间相比输入的时间会有所滞后。换句话说,就是myerode不能在数据刚进来的时候就输出数据。这是因为它得等待那sizexsize的矩阵在内存中成形。只有成形后,才能输出第一个处理后的像素点。此外,对于边缘位置的周围无法形成sizexsize正方形的点,该位置一致输出0。以上使用状态机实现。原理不难,但实操中有些细节一开始没考虑好,导致仿真调试失败了上百次才成功。
myimdilate和myerode是一堆双胞胎,因此不再赘述。
一张黑白二值图像,如何计算它的连通域的数目?刚来到这个问题时,笔者一无所知,不知道从何下手。问了数学专业的同学,同学说可以用深度搜索算法,给笔者发了一段C++代码。代码如下:
#include
using namespace std;
int a[1000][1000]={0};
int flag[1000][1000]={0};
int dx[4]={0,-1,0,1};
int dy[4]={1,0,-1,0}''
void deepfirst(a,b){
flag[a][b]=1;
for(int i=0;i<=3;i++){
int tempa=a+dx[i];
int tempb=b+dx[j];
if(flag[tempa][tempb]==1){
continue;
}
else{
deepfirst(tempa,tempb);
}
}
}
int main(){
\\input a[m][n]
sumnum=0;
for(int i=1;i<=m;i++){
for(int j=1;j<=n;j++){
if(a[i][j]=0){
continue;
}
else{
if(flag[i][j]=1){
continue;
}
else{//还没搜索过的1
deepfirst(i,j);
sumnum++;
}
}
}
}
}
注意:此flag非彼flag。上面的flag是时序用途。这里的flag是一个矩阵,用来插flag,是空间用途。
这个算法可以描述为:先对图像进行扫描,如果找到一个1,那么计数器加1,在它对应的flag矩阵位置上插上flag,然后就在它上下左右找,找到的话也插上flag,找不到就结束。有flag的地方不再寻找。
这个方法的确挺巧妙。但是要移植到FPGA上的话,有两个问题。
递归问题确实是一个问题。回想笔者在C程序设计上浅尝辄止的了解到的递归在计算机上实现的本质:程序是以堆栈的形式运行的。使用递归的时候,就需要额外申请动态的内存,等递归结束的条件满足的时候,就可以逐步释放。笔者认识到(以笔者目前的知识水平),不可能用verilog实现递归。verilog不允许模块自己调用自己,因为考虑到现实情况,在硬件上运行的模块只能是有限个,内存只能是有限的、静态的。
在这里,我们遇到了第二个重要的瓶颈。笔者苦苦思索两天后,想出了一个并不完美,但可以实现的算法。代码如下。
#include
void scan(int a[], int *num){
int scan_state = 0;
int j;
for (j=0; j<8; j++){ //遍历那一行的每一位,从低位开始
if (a[j] != 0 && scan_state==0){
(*num)++;
scan_state = 1;
}else if (a[j] != 0 && scan_state==1){
scan_state = 1;
}else { //如果遇到0
scan_state = 0;
}
}
}
int bwlabel(int (*a)[8]){
int tempAnd[8]; //上下两行与运算的结果
int num = 0;
int num1 = 0, numAnd = 0;
int i,k;
scan(a[0], &num); //第一行
printf("num = %d\n",num);
for (i=0; i<8-1; i++){
for (k=0; k<8; k++){
tempAnd[k] = a[i][k] && a[i+1][k];
//printf("%d, ",tempAnd[k]);
}
//printf("\n");
scan(a[i+1], &num1);
scan(tempAnd, &numAnd);
num += num1 - numAnd;
printf("num1 = %d ",num1);
printf("numAnd = %d ",numAnd);
printf("num = %d\n",num);
num1 = 0;
numAnd = 0;
}
return num;
}
int main()
{
int a[8][8] =
{
{0, 0, 0, 0, 0, 0, 0, 0,},
{0, 0, 0, 0, 0, 0, 0, 0,},
{0, 0, 0, 0, 1, 1, 1, 0,},
{0, 0, 0, 0, 1, 1, 1, 0,},
{0, 0, 0, 0, 1, 1, 1, 0,},
{0, 0, 0, 0, 0, 0, 0, 0,},
{0, 1, 0, 0, 0, 0, 0, 0,},
{0, 0, 0, 0, 0, 0, 0, 0,},
};
int number_a;
number_a = bwlabel(a);
printf("number_a = %d\n",number_a);
return 0;
}
输出结果:
num = 0
num1 = 0 numAnd = 0 num = 0
num1 = 1 numAnd = 0 num = 1
num1 = 1 numAnd = 1 num = 1
num1 = 1 numAnd = 1 num = 1
num1 = 0 numAnd = 0 num = 1
num1 = 1 numAnd = 0 num = 2
num1 = 0 numAnd = 0 num = 2
number_a = 2 //一共有两个连通区域
它的原理是:对矩阵逐行扫描,计算第一行有几段1的连续序列,存入num。扫描第二行时,先计算第二行有几段1的连续序列,存入num1,再计算第二行和第一行进行按位与的运算(&)后这一行数据有几段1的连续序列,存入numAnd。之后执行
num += num1 - numAnd;
num1 = 0;
numAnd = 0;
简而言之,可以看做我们把图像切成了一行一行的线段的数据。如果上一行与下一行&后为0,说明这两行毫无瓜葛,计数可以直接将上一行的线段数与下一行的线段数相加;如果上一行与下一行&后有1,说明上一行与下一行之间的1有连着的地方,所以我们要减去多余的numAnd。
FPGA表示很高兴,因为它最擅长做按位运算了。
具体在mybwlabel中实现的时候,这个计数其实用不着状态机。我们可以监测一行数据流的上升沿(posedge,也就是从0到1),一行数据流有几个上升沿,就说明有几段1的数据。
别高兴太早。前面说过它是不完美的。说它不完美,是因为只有当1的区域内部没有值为0的空洞的时候,它才能够正确计数。但是这似乎还是可以接受的。为什么?因为myimdilate模块对图像膨胀后可以帮我们填好那些空洞!
实验记录
上面数码管左边显示的是人数;右边是腐蚀核和膨胀和的大小笔者最终设定了四种卷积核的大小。设定不同大小是因为近处的照片和远处的照片的噪声大小不同。近处的人脸需要使用大核,远处的人脸需要使用小核,就像焦距的大小一样。
序号 | 腐蚀核大小 | 膨胀核大小 |
---|---|---|
00 | 11 | 21 |
01 | 21 | 31 |
10 | 31 | 41 |
11 | 41 | 51 |