前言
读者你好! 这是我第一次写博客,也是我第一次对图像处理和人脸检测进行研究,所以这篇文章会有许多错误,请读者多多包含。但正如标题所述,是用C从读取二进制图像文件开始,到基本的图像处理,再到实现人脸检测(传统VJ算法),过程中并不需要下载任何第三方库。这篇文章记录了作者一整个的学习流程。且文中会毫无保留的展示所有功能的实现,和其相应的代码。 注意:1.每个知识点间的代码都是相互独立的,可直接复制进行测试运行。
一. BMP文件的处理
1.1 为什么选择BMP文件
BMP文件是未经压缩的文件,它保存了原图的所有的像素信息,文件体积最大,文件所包含的信息也最多,BMP的文件结构相对与JPG和PNG简单很多,易于处理和学习。(网络上一般以JPG格式的图片为主,所以使用时要进行格式转换,建议用电脑自带的画图工具转换。有些格式转换工具可能会加入一些其它的字节信息,破坏其原本结构)
1.2 BMP文件结构的介绍
1.)bmp文件的组成 bmp文件 = bmp头部(bmp文件头(14字节)+位图信息头(40字节))+位图数据 (bmp文件中的数据一个字节为一组,正向写入倒向读出)
2.)bmp文件头(14字节) a.(2)424D---->‘BM’,表示这是Windows支持的位图格式。 b.(4)B6EA0500---->0050EAB6---->整个文件的大小(正向写入倒向读出) c.(2)0000---->保留段,通常为0 d.(2)0000---->保留段,通常为0 e.(4)36000000---->从文件头到位图数据需偏移的字节数,即整个bmp头部的大小(54字节)
2.)位图信息头(40字节) a.(4)28000000---->00000028---->位图信息头的大小(40) b.(4)E9000000---->000000E9---->图片宽度(正向写入倒向读出) c.(4)A0010000---->000001A0 ---->图片高度(正向写入倒向读出) d.(2)0100->0001---->颜色平面数,即调色盘数,恒等于1 d.(2)2000->0020---->图片颜色的位数(32) f.(4)00000000---->图像压缩比,不压缩则为 g.(4)00000000---->图像的大小,因为使用BI_RGB,所以设置为0 h.(4)00000000---->水平分辨率,缺省为0 i.(4)00000000---->垂直分辨率,缺省为0 j.(4)00000000---->说明本位图实际使用调色盘的颜色索引数,0表示全部 k.(4)00000000---->说明本位图重要调色盘的颜色索引数,0表示全都重要
1.3 BMP文件的读取和保存
代码说明与注意事项: 1. 代码中有详细的注释,核心就是C语言对二进制文件的读取和写入,但由于结构体对齐问题的存在,所以我选择最保险的,一条一条读取和写入的方式。 2. 虽然目前只能读取BMP文件,但还是保留了JPG的接口空位,后续可对其功能进行拓展。 3. BMP有32位24位16位8位,该程序只对前两个进行了处理 4. 计算机读取能被4整除大小的二进制文件时速度较快,所以读取24位图像的二进制文件时,每一行都会有补零操作。(每个图像每行补零的字节个数是固定的,且最多补3个字节)
计算要补零的字节个数的公式
(
3
?
w
+
k
)
mod
?
4
=
0
\left(3\cdot w+k\right)\operatorname{mod}4=0
(3?w+k)mod4=0 w:是图像的宽度 k:要补几个字节零 mod:取余
#include<stdio.h>
#include<stdlib.h>
#include<Windows.h>
#include<math.h>
#include<string.h>
char* GetFlieExta(char* filename)
{
int fileLen = strlen(filename);
int exLen = 0;
char fileExta[255];
memset(fileExta, 0, sizeof(fileExta));
for (int i = fileLen-1; i > 0; i--)
if (filename[i] == '.'){
exLen = fileLen - i;
break;
}
strncpy(fileExta, filename + fileLen - exLen, exLen);
return fileExta;
}
typedef struct tagBGRA
{
unsigned char blue;
unsigned char green;
unsigned char red;
unsigned char transparency;
}BGRA, * PBGRA;
typedef struct tagIMAGE
{
unsigned int w;
unsigned int h;
BGRA* color;
}IMAGE, * PIMAGE;
typedef struct tagBITMAP_HEAD_INFO
{
unsigned short bfType;
unsigned int bfSize;
unsigned short bfReserved1;
unsigned short bfReserved2;
unsigned int bfOffBits;
unsigned int biSize;
unsigned int biWidth;
unsigned int biHeight;
unsigned short biPlanes;
unsigned short biBitCount;
unsigned int biCompression;
unsigned int biSizeImage;
unsigned int biXPelsPerMeter;
unsigned int biYPelsPerMeter;
unsigned int biClrUsed;
unsigned int biClrImportant;
}BITMAP_HEAD_INFO,*PBITMAP_HEAD_INFO;
IMAGE Image_bmp_load(char* filename)
{
IMAGE im;
BITMAP_HEAD_INFO bmpHeadInfo;
FILE* fp;
if ((fp = fopen(filename, "rb")) == NULL)
printf("打开%s文件失败!\n", filename);
fread(&bmpHeadInfo.bfType, 1, sizeof(bmpHeadInfo.bfType), fp);
fread(&bmpHeadInfo.bfSize, 1, sizeof(bmpHeadInfo.bfSize), fp);
fread(&bmpHeadInfo.bfReserved1, 1, sizeof(bmpHeadInfo.bfReserved1), fp);
fread(&bmpHeadInfo.bfReserved2, 1, sizeof(bmpHeadInfo.bfReserved2), fp);
fread(&bmpHeadInfo.bfOffBits, 1, sizeof(bmpHeadInfo.bfOffBits), fp);
fread(&bmpHeadInfo.biSize, 1, sizeof(bmpHeadInfo.biSize), fp);
fread(&bmpHeadInfo.biWidth, 1, sizeof(bmpHeadInfo.biWidth), fp);
fread(&bmpHeadInfo.biHeight, 1, sizeof(bmpHeadInfo.biHeight), fp);
fread(&bmpHeadInfo.biPlanes, 1, sizeof(bmpHeadInfo.biPlanes), fp);
fread(&bmpHeadInfo.biBitCount, 1, sizeof(bmpHeadInfo.biBitCount), fp);
fread(&bmpHeadInfo.biCompression, 1, sizeof(bmpHeadInfo.biCompression), fp);
fread(&bmpHeadInfo.biSizeImage, 1, sizeof(bmpHeadInfo.biSizeImage), fp);
fread(&bmpHeadInfo.biXPelsPerMeter, 1, sizeof(bmpHeadInfo.biXPelsPerMeter), fp);
fread(&bmpHeadInfo.biYPelsPerMeter, 1, sizeof(bmpHeadInfo.biYPelsPerMeter), fp);
fread(&bmpHeadInfo.biClrUsed, 1, sizeof(bmpHeadInfo.biClrUsed), fp);
fread(&bmpHeadInfo.biClrImportant, 1, sizeof(bmpHeadInfo.biClrImportant), fp);
BGRA* bgra = (BGRA*)malloc(sizeof(BGRA) * (bmpHeadInfo.biWidth * bmpHeadInfo.biHeight));
fseek(fp, bmpHeadInfo.bfOffBits, SEEK_SET);
if (bmpHeadInfo.biBitCount == 32)
{
for (unsigned int i = 0; i < bmpHeadInfo.biWidth * bmpHeadInfo.biHeight; i++)
fread(&bgra[i], 1, sizeof(BGRA), fp);
}
else if (bmpHeadInfo.biBitCount == 24)
{
int k = 4 * (3 * bmpHeadInfo.biWidth / 4 + 1) - 3 * bmpHeadInfo.biWidth;
for (unsigned int i = 0; i < bmpHeadInfo.biWidth * bmpHeadInfo.biHeight; i++)
{
if (k != 4 && (ftell(fp)- 54 + k )% (3 * bmpHeadInfo.biWidth + k)==0)
fseek(fp, ftell(fp) + k, SEEK_SET);
fread(&bgra[i].blue, 1, sizeof(unsigned char), fp);
fread(&bgra[i].green, 1, sizeof(unsigned char), fp);
fread(&bgra[i].red, 1, sizeof(unsigned char), fp);
bgra[i].transparency = (unsigned char)0xFF;
}
}
im.color = bgra;
im.w = bmpHeadInfo.biWidth;
im.h = bmpHeadInfo.biHeight;
fclose(fp);
return im;
}
void Image_bmp_save(char* filename,IMAGE im)
{
FILE* fp = fopen(filename, "wb");
unsigned short bfType = 0x4D42;
unsigned int bfSize = im.w * im.h * 4 + 54;
unsigned short bfReserved1 = 0;
unsigned short bfReserved2 = 0;
unsigned int bfOffBits = 54;
unsigned int biSize = 40;
unsigned int biWidth = im.w;
unsigned int biHeight = im.h;
unsigned short biPlanes = 1;
unsigned short biBitCount = 32;
unsigned int biCompression = 0;
unsigned int biSizeImage = 0;
unsigned int biXPelsPerMeter = 0;
unsigned int biYPelsPerMeter = 0;
unsigned int biClrUsed = 0;
unsigned int biClrImportant = 0;
fwrite(&bfType, 2, 1, fp);
fwrite(&bfSize, 4, 1, fp);
fwrite(&bfReserved1, 2, 1, fp);
fwrite(&bfReserved2, 2, 1, fp);
fwrite(&bfOffBits, 4, 1, fp);
fwrite(&biSize, 4, 1, fp);
fwrite(&biWidth, 4, 1, fp);
fwrite(&biHeight, 4, 1, fp);
fwrite(&biPlanes, 2, 1, fp);
fwrite(&biBitCount, 2, 1, fp);
fwrite(&biCompression, 4, 1, fp);
fwrite(&biSizeImage, 4, 1, fp);
fwrite(&biXPelsPerMeter, 4, 1, fp);
fwrite(&biYPelsPerMeter, 4, 1, fp);
fwrite(&biClrUsed, 4, 1, fp);
fwrite(&biClrImportant, 4, 1, fp);
fwrite(im.color, sizeof(BGRA) * im.w * im.h, 1, fp);
fclose(fp);
}
IMAGE Image_load(char* filename)
{
IMAGE im;
char* fileEx= GetFlieExta(filename);
if (strcmp(fileEx, ".bmp") == 0)
im = Image_bmp_load(filename);
return im;
}
void Image_save(char* filename, IMAGE im)
{
char* fileEx = GetFlieExta(filename);
if (strcmp(fileEx, ".bmp") == 0)
Image_bmp_save(filename, im);
}
void Image_show(char* filename)
{
system(filename);
}
void Image_free(IMAGE im)
{
free(im.color);
}
二. 图片的形状变换
2.1 图像一维坐标与二维坐标的转换
注意:一维坐标与二维坐标的转换是这篇文章最最基础的核心知识点,贯穿全文的所有代码,一定要理解。图片的核心数据是位图信息头中的RGB值,它可以看作一个巨大的一维数组。图片的第一个像素位于左下角,从左到右,从上到下依次排列。但在图像处理中二维坐标更易处理。所以可将一维数组的下标看作一维坐标,并在需要时转化成二维坐标。
一维坐标转二维坐标
x
=
i
w
x=\frac{i}{w}
x=wi?
y
=
i
m
o
d
??
w
y=i\mod{w}
y=imodw 二维坐标转一维坐标
i
=
x
+
y
?
w
i=x+y*w
i=x+y?w 除法是整除,mod是取余,
i
i
i是一维数组下标,
w
w
w是图片的宽度
2.2 图像的任意大小的缩放
2.2.1 最近邻插值法(不推荐使用)
算法核心:1. 通过新图的坐标计算原图的坐标,逆向映射。 2. 通过原图的坐标计算与该点最邻近的点坐标,原图坐标(0.3,0.3)与它最 邻近的点的坐标为(0,0),即新图坐标中该点的RGB等于原图(0,0)点的RGB。即该点RGB等于最近点的RGB
通过新图的坐标计算原图的坐标
o
l
d
X
=
n
e
w
X
?
o
l
d
W
n
e
w
W
oldX=newX*\frac{oldW}{newW}
oldX=newX?newWoldW? ????
o
l
d
Y
=
n
e
w
Y
?
o
l
d
H
n
e
w
H
oldY=newY*\frac{oldH}{newH}
oldY=newY?newHoldH?
IMAGE Transform_shape_nearest(IMAGE im, unsigned int newWidth, unsigned int newHeight)
{
float fx, fy;
float dx, dy;
BGRA* bgra = (BGRA*)malloc(sizeof(BGRA) * newWidth * newHeight);
for (unsigned int i = 0; i < newWidth * newHeight; i++)
{
fx = (i % newWidth) * ((float)im.w / newWidth);
fy = (i / newWidth) * ((float)im.h / newHeight);
dx = fx - (int)fx;
dy = fy - (int)fy;
fx = (dx <= 0.5 ? (int)fx : (int)fx + 1);
fy = (dy <= 0.5 ? (int)fy : (int)fy + 1);
unsigned int k = fx + fy * im.w;
if (k >= im.w * im.h)
k = im.w * im.h - 1;
bgra[i].blue = im.color[k].blue;
bgra[i].green = im.color[k].green;
bgra[i].red = im.color[k].red;
bgra[i].transparency = 255;
}
free(im.color);
im.color = bgra;
im.w = newWidth;
im.h = newHeight;
return im;
}
2.2.1 双线性插值法(推荐使用)
算法核心:1. 通过新图的坐标计算原图的坐标,,逆向映射。 2. 通过坐标值,按权重分配像素颜色值,离谁近谁权重大。
计算(0.4,0) (0.4,0)blue = (0,0)blue
?
*
? (1-0.4) + (1,0)blue
?
*
? 0.4 计算(0.4,1) (0.4,1)blue = (0,1)blue
?
*
? (1-0.4) + (1,1)blue
?
*
? 0.4 计算(0.4,0.6) (0.4,0.6)blue = (0.4,0)blue
?
*
? (1-0.6) + (0.4,1)blue
?
*
? 0.6
令四个角上的点的一维坐标为k1,k2,k3,k4。所求点的小数部分为dx,dy可得
c
o
l
o
r
=
(
k
1
?
(
1
?
d
x
)
+
k
2
?
d
x
)
?
(
1
?
d
y
)
+
(
k
3
?
(
1
?
d
x
)
+
k
4
?
d
x
)
?
d
y
color=\left(k_{1}\cdot\left(1-dx\right)+k_{2}\cdot dx\right)\cdot\left(1-dy\right)+\left(k_{3}\cdot\left(1-dx\right)+k_{4}\cdot dx\right)\cdot dy
color=(k1??(1?dx)+k2??dx)?(1?dy)+(k3??(1?dx)+k4??dx)?dy
IMAGE Transform_shape_linear(IMAGE im, unsigned int newWidth, unsigned int newHeight)
{
float fx, fy, dx, dy;
int k1, k2, k3, k4;
BGRA* bgra = (BGRA*)malloc(sizeof(BGRA) * newWidth * newHeight);
for (unsigned int i = 0; i < newWidth * newHeight; i++)
{
fx = (i % newWidth) * ((float)im.w / newWidth);
fy = (i / newWidth) * ((float)im.h / newHeight);
dx = fx - (int)fx;
dy = fy - (int)fy;
fx = (int)fx;
fy = (int)fy;
k1 = fx + fy * im.w;
k2 = fx + 1 + fy * im.w;
k3 = fx + (fy + 1) * im.w;
k4 = fx + 1 + (fy + 1) * im.w;
if (k1 >= im.w * im.h)
k1 = im.w * im.h - 1;
if (k2 >= im.w * im.h)
k2 = im.w * im.h - 1;
if (k3 >= im.w * im.h)
k3 = im.w * im.h - 1;
if (k4 >= im.w * im.h)
k4 = im.w * im.h - 1;
bgra[i].blue = (im.color[k1].blue * (1 - dx) + im.color[k2].blue * dx) * (1 - dy) + (im.color[k3].blue * (1 - dx) + im.color[k4].blue * dx) * dy;
bgra[i].green = (im.color[k1].green * (1 - dx) + im.color[k2].green * dx) * (1 - dy) + (im.color[k3].green * (1 - dx) + im.color[k4].green * dx) * dy;
bgra[i].red = (im.color[k1].red * (1 - dx) + im.color[k2].red * dx) * (1 - dy) + (im.color[k3].red * (1 - dx) + im.color[k4].red * dx) * dy;
bgra[i].transparency = 255;
}
free(im.color);
im.color = bgra;
im.w = (int)newWidth;
im.h = (int)newHeight;
return im;
}
左:最近邻(有明显锯齿),右:双线性 ?
2.3 图像的任意角度的旋转
- 由矩阵旋转可得旋转后的坐标与旋转前坐标的转换
x
′
′
=
x
′
cos
?
θ
+
y
′
sin
?
θ
x''=x'\cos\theta+y'\sin\theta
x′′=x′cosθ+y′sinθ ?????
y
′
′
=
y
′
cos
?
θ
?
x
′
sin
?
θ
y''=y'\cos\theta-x'\sin\theta
y′′=y′cosθ?x′sinθ
- 由图可知,旋转后的图片离第一象限,
x
x
x轴偏移了
b
x
bx
bx,
y
y
y轴偏移了
b
y
by
by,所以公式调整为
x
′
′
=
x
′
cos
?
θ
+
y
′
sin
?
θ
+
b
x
x''=x'\cos\theta+y'\sin\theta+bx
x′′=x′cosθ+y′sinθ+bx?????
y
′
′
=
y
′
cos
?
θ
?
x
′
sin
?
θ
+
b
y
y''=y'\cos\theta-x'\sin\theta+by
y′′=y′cosθ?x′sinθ+by
- 实现的核心:通过原图坐标计算新图坐标,向前映射。
- 注意旋转后的图片大小也会改变
- 旋转后的图片会有许多空白区域,最后要对这些空白区域进行填充
IMAGE Transform_shape_whirl(IMAGE im, float angle)
{
angle = 3.141592 * angle / 180;
float cosnum = (float)cos(angle);
float sinnum = (float)sin(angle);
int fx1 = 0;
int fy1 = 0;
int fx2 = im.w - 1;
int fy2 = 0;
int fx3 = 0;
int fy3 = im.h - 1;
int fx4 = im.w - 1;
int fy4 = im.h - 1;
int nx1 = 0;
int ny1 = 0;
int nx2 = (int)(fx2 * cosnum + fy2 * sinnum);
int ny2 = (int)(fy2 * cosnum - fx2 * sinnum);
int nx3 = (int)(fx3 * cosnum + fy3 * sinnum);
int ny3 = (int)(fy3 * cosnum - fx3 * sinnum);
int nx4 = (int)(fx4 * cosnum + fy4 * sinnum);
int ny4 = (int)(fy4 * cosnum - fx4 * sinnum);
unsigned int width = abs(max(max(nx1, nx2), max(nx3, nx4))) + abs(min(min(nx1, nx2), min(nx3, nx4))) + 1;
unsigned int hight = abs(max(max(ny1, ny2), max(ny3, ny4))) + abs(min(min(ny1, ny2), min(ny3, ny4))) + 1;
int bx = abs(min(min(nx1, nx2), min(nx3, nx4)));
int by = abs(min(min(ny2, ny3), ny4));
BGRA* bgra = (BGRA*)calloc(width * hight, sizeof(BGRA));
for (unsigned int i = 0; i < im.w * im.h; i++)
{
int k = ((i % im.w) * cosnum + (i / im.w) * sinnum + bx) + (int)((i / im.w) * cosnum - (i % im.w) * sinnum + by) * width;
if (k >= width * hight)
k = width * hight - 1;
bgra[k].blue = im.color[i].blue;
bgra[k].green = im.color[i].green;
bgra[k].red = im.color[i].red;
bgra[k].transparency = 255;
}
for (unsigned int i = 0; i < width * hight; i++)
{
if (bgra[i].transparency != 255 && bgra[i + 1].transparency == 255)
{
bgra[i].blue = bgra[i - 1].blue;
bgra[i].green = bgra[i - 1].green;
bgra[i].red = bgra[i - 1].red;
bgra[i].transparency = 255;
}
}
free(im.color);
im.color = bgra;
im.w = width;
im.h = hight;
return im;
}
2.4 图像的镜像翻转
图像的镜像翻转分为水平翻转和垂直翻转 水平翻转是
y
y
y坐标不变,
x
x
x坐标翻转 垂直翻转是
x
x
x坐标不变,
y
y
y坐标翻转
#define UPTURN_MODE_HORIZONTAL 0
#define UPTURN_MODE_VERTICAL 1
IMAGE Transform_shape_upturn(IMAGE im, int upturn_mode)
{
BGRA* bgra = (BGRA*)malloc(sizeof(BGRA) * im.w * im.h);
if(upturn_mode == UPTURN_MODE_HORIZONTAL)
for (unsigned int i = 0; i < im.w * im.h; i++)
bgra[i] = im.color[(im.w - 1 - (i % im.w)) + i / im.w * im.w];
else if(upturn_mode == UPTURN_MODE_VERTICAL)
for (unsigned int i = 0; i < im.w * im.h; i++)
bgra[i] = im.color[(i % im.w) + (im.h - 1 - i / im.w ) * im.w];
free(im.color);
im.color = bgra;
return im;
}
三. 图片的颜色预处理
图片的颜色预处理中部分算法比较简单所以不做过多的讲解,直接看公式即可明白。
3.1 灰度图(4种算法)
3.1.1 加权法(推荐使用)
c
o
l
o
r
=
b
l
u
e
?
144
+
g
r
e
e
n
?
587
+
r
e
d
?
299
1000
color=\frac{blue\cdot144+green\cdot587+red\cdot299}{1000}
color=1000blue?144+green?587+red?299?
3.1.2 最值法
c
o
l
o
r
=
m
a
x
(
b
l
u
e
,
g
r
e
e
n
,
r
e
d
)
color=max(blue,green,red)
color=max(blue,green,red)
3.1.2 均值法
c
o
l
o
r
=
b
l
u
e
+
g
r
e
e
n
+
r
e
d
3
color=\frac{blue+green+red}{3}
color=3blue+green+red?
3.1.2 分量法
c
o
l
o
r
=
(
r
e
d
/
g
r
e
e
n
/
b
l
u
e
)
color=(red/green/blue)
color=(red/green/blue)
#define GRAY_MODE_WEIGHT 1
#define GRAY_MODE_BEST 2
#define GRAY_MODE_AVERAGE 3
#define GRAY_MODE_PART_RED 4
#define GRAY_MODE_PART_GREEN 5
#define GRAY_MODE_PART_BLUE 6
void Transform_color_grayscale(IMAGE im, int grayscale_mode)
{
int color = 0;
switch (grayscale_mode)
{
case GRAY_MODE_WEIGHT:
{
for (unsigned int i = 0; i < im.w * im.h; i++)
{
color = (im.color[i].blue * 114 + im.color[i].green * 587 + im.color[i].red * 299) / 1000;
im.color[i].blue = color;
im.color[i].green = color;
im.color[i].red = color;
}
break;
}
case GRAY_MODE_BEST:
{
for (unsigned int i = 0; i < im.w * im.h; i++)
{
color = im.color[i].blue;
if (color < im.color[i].red)
color = im.color[i].red;
if (color < im.color[i].green)
color = im.color[i].green;
im.color[i].blue = color;
im.color[i].green = color;
im.color[i].red = color;
}
break;
}
case GRAY_MODE_AVERAGE:
{
for (unsigned int i = 0; i < im.w * im.h; i++)
{
color = (im.color[i].blue + im.color[i].green + im.color[i].red) / 3;
im.color[i].blue = color;
im.color[i].green = color;
im.color[i].red = color;
}
break;
}
case GRAY_MODE_PART_RED:
{
for (unsigned int i = 0; i < im.w * im.h; i++)
{
im.color[i].blue = im.color[i].red;
im.color[i].green = im.color[i].red;
}
break;
}
case GRAY_MODE_PART_GREEN:
{
for (unsigned int i = 0; i < im.w * im.h; i++)
{
im.color[i].blue = im.color[i].green;
im.color[i].red = im.color[i].green;
}
break;
}
case GRAY_MODE_PART_BLUE:
{
for (unsigned int i = 0; i < im.w * im.h; i++)
{
im.color[i].green = im.color[i].blue;
im.color[i].red = im.color[i].blue;
}
break;
}
}
}
图片顺序:加权法,最值法,均值法,分量法_RED ?
3.2 二值图
二值图是指由只由黑白两种颜色的像素组成的图片,二值图的质量会直接影响到提取到的图片特征的是否有效,而决定二值图的质量的核心就是阈值的计算,像素值大于等于阈值则取255,小于阈值则取0。
3.2.1 自定义阈值法
void Transform_color_BW_DIY(IMAGE im, unsigned char Threshold)
{
unsigned char color = 0;
for (unsigned int i = 0; i < im.w * im.h; i++)
{
color = (im.color[i].blue * 114 + im.color[i].green * 587 + im.color[i].red * 299) / 1000;
if (color >= Threshold)
color = 255;
else
color = 0;
im.color[i].blue = color;
im.color[i].green = color;
im.color[i].red = color;
}
}
3.2.2 最大类间方差法(大津法OTSU)
大津法OTSU是假设阈值将图像分成背景和前景两部分。计算背景和前景之间的类间方差,类间方差越大说明背景和前景之间的差别越大,二值图的效果越好。大津法OSTU,适用双峰直方图。当图像的整体颜色差别不大时,不推荐使用。
类间方差计算公式
g
=
w
前
1
?
w
前
?
(
u
前
?
u
总
)
2
g=\frac{w_{前}}{1-w_{前}}\cdot\left(u_{前}-u_{总}\right)^{2}
g=1?w前?w前???(u前??u总?)2
w
前
w_{前}
w前?:前景像素数的占比
u
前
u_{前}
u前?:前景的平局灰度
u
总
u_{总}
u总?:图像的平局灰度
g
g
g:类间方差
整理可得
g
=
n
前
n
总
1
?
n
前
n
总
?
(
∑
i
=
T
i
=
255
n
i
?
i
n
总
?
∑
i
=
0
i
=
255
n
i
?
i
n
总
)
2
g=\frac{\frac{n_{前}}{n_{总}}}{1-\frac{n_{前}}{n_{总}}}\cdot\left(\frac{\sum_{i=T}^{i=255}n_{i}\cdot i}{n_{总}}-\frac{\sum_{i=0}^{i=255}n_{i}\cdot i}{n_{总}}\right)^{2}
g=1?n总?n前??n总?n前????(n总?∑i=Ti=255?ni??i??n总?∑i=0i=255?ni??i?)2
n
前
n_{前}
n前?:前景的像素数
n
总
n_{总}
n总?:总的像素数
n
i
n_{i}
ni?:对应像素颜色的像素数
这里计算平局灰度,读者可能有点不理解,举个例子:一组7人每人10元,二组6人每人8元,三组11人每人5元,求计算平均每人几元。
解
7
?
10
+
6
?
8
+
11
?
5
7
+
6
+
11
=
7.2
\frac{7*10+6*8+11*5}{7+6+11}=7.2
7+6+117?10+6?8+11?5?=7.2
void Transform_color_BW_OSTU(IMAGE im)
{
int colorMap[256] = { 0 };
float w0 = 0;
unsigned int u0 = 0;
unsigned int u = 0;
float g = 0;
unsigned char T = 0;
for (unsigned int i = 0; i < im.w * im.h; i++)
colorMap[im.color[i].blue] += 1;
for (int i = 0; i < 256; i++)
u += colorMap[i] * i;
u /= (im.h * im.w);
for(unsigned int m = 0 ; m < 256; m++)
{
for (int n = m; n < 256; n++)
{
w0 += colorMap[n];
u0 += colorMap[n] * n;
}
u0 /= w0;
w0 /= (im.h * im.w);
if((w0 / (1 - w0) * (u0 - u)* (u0 - u)) > g)
g = w0 / (1 - w0) * (u0 - u)* (u0 - u), T = m;
}
Transform_color_BW_DIY(im, T);
}
3.2.3 三角法TRIANGLE
如图,在灰度直方图中,找到最低点和最高点,计算出这两点的函数(黄色直线),分别计算最低点和最高点之间的点到黄色直线的距离
d
d
d,当
d
d
d取到最大时,取到
T
T
T阈值。三角法TRIANGLE,适用单峰直方图。当图像的整体颜色差别不大时,不推荐使用
计算公式 ? ? ? ?
d
=
∣
A
x
0
+
B
y
0
+
C
∣
A
2
+
B
2
d=\frac{\left|Ax_{0}+By_{0}+C\right|}{\sqrt{A^{2}+B^{2}}}
d=A2+B2
?∣Ax0?+By0?+C∣? ?? ? ?
y
=
k
x
+
b
y=kx+b
y=kx+b 联立可得
d
=
∣
?
k
x
0
+
y
0
?
b
∣
1
+
k
2
d=\frac{\left|-kx_{0}+y_{0}-b\right|}{\sqrt{1+k^{2}}}
d=1+k2
?∣?kx0?+y0??b∣?
void Transform_color_BW_TRIANGLE(IMAGE im)
{
int colorMap[256] = { 0 };
unsigned char minColor = 0;
unsigned int minCount = 0;
unsigned char maxColor = 0;
unsigned int maxCount = 0;
int d = 0;
unsigned char T = 0;
for (unsigned int i = 0; i < im.w * im.h; i++)
colorMap[im.color[i].blue] += 1;
for (int i = 0; i < 256; i++)
{
if (im.color[i].blue < minColor)
minColor = im.color[i].blue, minCount = colorMap[im.color[i].blue];
if (im.color[i].blue > maxColor)
maxColor = im.color[i].blue, maxCount = colorMap[im.color[i].blue];
}
float k = ((float)maxCount - minCount) / ((float)maxColor - minColor);
float b = maxCount - k * maxColor;
for (unsigned int n = minColor; n <= maxColor; n++)
if (abs((int)(-k * n + colorMap[n] - b)) / sqrt((double)(1 + k * k)) > b)
b = abs((int)(-k * n + colorMap[n] - b)) / sqrt((double)(1 + k * k)), T = n;
Transform_color_BW_DIY(im, T);
}
识别出了部分云彩
3.2.4 自适应阈值法
自适应阈值法是一种,类卷积的操作(卷积会在下文详细讲解),其核心是选取一定大小的区域(区域的边长为奇数),用这区域的像素平均值来作为这一个区域的阈值。自适应阈值法,当图片线条多且密时,不推荐使用
IMAGE Transform_color_BW_Adaptive(IMAGE im, int areaSize)
{
BGRA* bgra = (BGRA*)malloc(sizeof(BGRA) * im.w * im.h);
int* p = (int*)malloc(sizeof(int) * areaSize);
int k = (int)(sqrt((double)areaSize)) / 2;
for (unsigned int i = 0; i < im.w * im.h; i++)
{
int t = 0;
for (int n = k; n >= -k; n--)
for (int m = -k; m <= k; m++)
{
p[t] = ((i % im.w) + m) + (i / im.w + n) * im.w;
t++;
}
for (int j = 0; j < areaSize; j++)
if (p[j] < 0 || p[j] >= im.w * im.h)
p[j] = i;
unsigned int color = 0;
for (int j = 0; j < areaSize; j++)
color += im.color[p[j]].blue;
color /= areaSize;
if (im.color[i].blue >= color)
bgra[i].blue = 255;
else
bgra[i].blue = 0;
bgra[i].green = bgra[i].blue;
bgra[i].red = bgra[i].blue;
}
free(p);
free(im.color);
im.color = bgra;
return im;
}
处理山 处理人脸
3.2.5 拓展:用二值图表示灰度变化
核心思想是通过像素的疏密来表示灰度的变化,其实现的代码和自适应阈值法的代码极为相识
void Transform_color_BW_grayscale(IMAGE im, int areaSize)
{
int* p = (int*)malloc(sizeof(int) * areaSize);
int k = (int)(sqrt((double)areaSize)) / 2;
for (unsigned int i = 0; i < im.w * im.h; i++)
{
int t = 0;
for (int n = k; n >= -k; n--)
for (int m = -k; m <= k; m++)
{
p[t] = ((i % im.w) + m) + (i / im.w + n) * im.w;
t++;
}
for (int j = 0; j < areaSize; j++)
if (p[j] < 0 || p[j] >= im.w * im.h)
p[j] = i;
unsigned int color = 0;
for (int j = 0; j < areaSize; j++)
color += im.color[p[j]].blue;
color /= areaSize;
if (im.color[i].blue >= color)
im.color[i].blue = 255;
else
im.color[i].blue = 0;
im.color[i].green = im.color[i].blue;
im.color[i].red = im.color[i].blue;
}
free(p);
}
这是用彩色图转的看起来效果较好,也可以用灰度图
3.3 反色
c
o
l
o
r
′
′
=
255
?
c
o
l
o
r
′
color'' = 255-color'
color′′=255?color′
void Transform_color_opposite(IMAGE im)
{
for (unsigned int i = 0; i < im.w * im.h; i++)
{
im.color[i].green = 255 - im.color[i].green;
im.color[i].blue = 255 - im.color[i].blue;
im.color[i].red = 255 - im.color[i].red;
}
}
3.4 直方图均衡化
1. 直方图均衡化:是一种增强图像对比度的方法,即让亮的地方更亮,暗的地方更暗,主要用于图像整体颜色偏淡,图像整体太亮,图像整体太暗,等图像整体变化度不够的情况。由图可知,其主要思想是将一副图像的颜色直方图分布变成近似均匀分布,从而增强图像的对比度。 2. 直方图均衡化核心公式
c
o
l
o
r
=
(
R
G
B
max
?
?
R
G
B
min
?
)
N
∑
i
=
0
k
n
i
color=\frac{\left(RGB\max-RGB\min\right)}{N}\sum_{i=0}^{k}ni
color=N(RGBmax?RGBmin)?i=0∑k?ni
R
G
B
max
?
RGB\max
RGBmax是:最大的RGB值一般为255
R
G
B
min
?
RGB\min
RGBmin是:最小的RGB值一般为0 N是:总的像素数一般为
w
?
h
w*h
w?h
k
k
k 是:color的RGB值
∑
i
=
0
k
n
i
\sum_{i=0}^{k}ni
∑i=0k?ni 是:所有小于color的RGB值的像素的个数之和
整理后的公式
c
o
l
o
r
=
255
?
0
w
?
h
∑
i
=
0
k
n
i
color=\frac{255-0}{w\cdot h}\sum_{i=0}^{k}ni
color=w?h255?0?i=0∑k?ni
3.两种不同的直方图均衡化(实现代码差不多,但效果区别很大)
void Transform_color_Histogram_part(IMAGE im)
{
int Accumulate = 0;
unsigned char color = 0;
int allBlue[256] = { 0 };
int allGreen[256] = { 0 };
int allRed[256] = { 0 };
for (unsigned int i = 0; i < im.w * im.h; i++)
{
allBlue[im.color[i].blue] += 1;
allGreen[im.color[i].green] += 1;
allRed[im.color[i].red] += 1;
}
for (unsigned int i = 0; i < im.w * im.h; i++)
{
for (int j = 0; j <= im.color[i].blue; j++)
Accumulate += allBlue[j];
color = (255 - 0) * Accumulate / (im.w * im.h);
im.color[i].blue = color;
Accumulate = 0;
for (int j = 0; j <= im.color[i].green; j++)
Accumulate += allGreen[j];
color = (255 - 0) * Accumulate / (im.w * im.h);
im.color[i].green = color;
Accumulate = 0;
for (int j = 0; j <= im.color[i].red; j++)
Accumulate += allRed[j];
color = (255 - 0) * Accumulate / (im.w * im.h);
im.color[i].red = color;
Accumulate = 0;
}
}
void Transform_color_Histogram_all(IMAGE im)
{
int Accumulate = 0;
unsigned char color = 0;
int allColor[256] = { 0 };
for (unsigned int i = 0; i < im.w * im.h; i++)
{
allColor[im.color[i].blue] += 1;
allColor[im.color[i].green] += 1;
allColor[im.color[i].red] += 1;
}
for (unsigned int i = 0; i < im.w * im.h; i++)
{
for (int j = 0; j <= im.color[i].blue; j++)
Accumulate += allColor[j];
color = (255 - 0) * Accumulate / (im.w * im.h);
im.color[i].blue = color;
Accumulate = 0;
for (int j = 0; j <= im.color[i].green; j++)
Accumulate += allColor[j];
color = (255 - 0) * Accumulate / (im.w * im.h);
im.color[i].green = color;
Accumulate = 0;
for (int j = 0; j <= im.color[i].red; j++)
Accumulate += allColor[j];
color = (255 - 0) * Accumulate / (im.w * im.h);
im.color[i].red = color;
Accumulate = 0;
}
}
分步计算,效果更加柔和 整体计算,效果更加尖锐
注意:上文写二值图时介绍的大津法和三角法,都在图像的整体颜色差别不大时处理效果不佳,而直方均衡化就抑制这问题。
四. 图像的卷积操作
4.1 卷积核
- 卷积核是图像处理时,给定输入图像,输入图像中一个小区域中像素加权平均后成为输出图像中的每个对应像素,其中权值由一个函数定义,这个函数称为卷积核。通俗的讲就是一个一维或二维数组,但注意卷积核一般是
3
?
3
3*3
3?3,
5
?
5
5*5
5?5,
7
?
7
7*7
7?7 奇数大小的。卷积核中的数加起来一般为0。 灵活运用不同的卷积核可以实现很多功能。
- 卷积核的操作和计算方法,如图所示,
3
?
3
3*3
3?3的卷积核在
w
?
h
w*h
w?h的
6
?
6
6*6
6?6图像上进行移动,将卷积核与图像重合部分的数,分别相乘再相加,即为卷积核中心的值(黄色框区域)当该值小于0时,一般会取绝对值。
- 不与卷积核重合的部分,即超出图像边界的部分的值一般为设为0,或等于卷积核中心的值。
- 类卷积操作,指并没有用到卷积核,但操作方法与卷积操作类似
4.1.1 自定义卷积操作
卷积操作的代码实现具有很高的重复性,所以先写一个自定义卷积操作(代码与上文二值图的自定义阈值法的实现相类似)可以极大的缩减代码。类卷积操作的代码实现还是要手写。
unsigned char Tool_RBG(int BRRA)
{
if (BRRA > 255)
return (unsigned char)255;
else if (BRRA < 0)
return (unsigned char)0;
else
return (unsigned char)BRRA;
}
IMAGE Kernels_use_DIY(IMAGE im, double* kernels, int areaSize, int modulus)
{
BGRA* bgra = (BGRA*)malloc(sizeof(BGRA) * im.w * im.h);
int* p = (int*)malloc(sizeof(int) * areaSize);
int k = (int)(sqrt((double)areaSize)) / 2;
for (unsigned int i = 0; i < im.w * im.h; i++)
{
int t = 0;
for(int n = k; n >= -k; n--)
for (int m = -k; m <= k; m++)
p[t] = ((i % im.w) + m) + (i / im.w + n) * im.w, t++;
for (int j = 0; j < areaSize; j++)
if (p[j] < 0 || p[j] >= im.w * im.h)
p[j] = i;
int blue = 0, green = 0, red = 0;
for (int j = 0; j < areaSize; j++)
{
blue += im.color[p[j]].blue * kernels[j];
green += im.color[p[j]].green * kernels[j];
red += im.color[p[j]].red * kernels[j];
}
bgra[i].blue = Tool_RBG(blue * modulus);
bgra[i].green = Tool_RBG(green * modulus);
bgra[i].red = Tool_RBG(red * modulus);
}
free(p);
free(im.color);
im.color = bgra;
return im;
}
4.2 滤波
图像滤波即在尽量保留图像细节特征的条件下对目标图像的噪声进行抑制。
4.2.1 中值滤波
中值滤波主要用于处理脉冲噪声(椒盐噪声),它是属于随机噪声,即在图像上随机出现的黑点或白点。中值滤波,是将卷积区域内的值按顺序排列,并取中值,作为该点的像素。
IMAGE Wavefiltering_Median(IMAGE im)
{
BGRA* bgra = (BGRA*)malloc(sizeof(BGRA) * im.w * im.h);
for (int unsigned i = 0; i < im.w * im.h; i++)
{
int p[9] =
{
i + im.w - 1,i + im.w,i + im.w + 1,
i - 1,i,i + 1,
i - im.w - 1,i - im.w,i - im.w + 1
};
for (int j = 0; j < 9; j++)
if (p[j] < 0 || p[j] >= im.w * im.h)
p[j] = i;
int color[9] =
{
im.color[p[0]].blue, im.color[p[1]].blue, im.color[p[2]].blue,
im.color[p[3]].blue, im.color[p[4]].blue, im.color[p[5]].blue,
im.color[p[6]].blue, im.color[p[7]].blue, im.color[p[8]].blue
};
for (int n = 0; n < 9; n++)
for (int m = n; m < 9; m++)
if (color[n] > color[m])
{
color[n] ^= color[m];
color[m] ^= color[n];
color[n] ^= color[m];
}
bgra[i].blue = color[4];
bgra[i].green = color[4];
bgra[i].red = color[4];
}
free(im.color);
im.color = bgra;
return im;
}
滤波前后比较
4.2.2 高斯滤波
高斯噪声是指它的概率密度函数服从高斯分布(即正态分布)的一类噪声。常见的高斯噪声包括起伏噪声、宇宙噪声、热噪声和散粒噪声等等。
高斯滤波公式
1
16
[
1
2
1
2
4
2
1
2
1
]
\frac{1}{16}\begin{bmatrix} 1 & 2 & 1\\\\ 2 & 4 & 2\\\\ 1 & 2 & 1\\\\ \end{bmatrix}
161??????????121?242?121??????????
double KERNELS_Wave_Gauss[9] =
{
1, 2, 1,
2, 4, 2,
1, 2 ,1
};
IMAGE Wavefiltering_Gauss(IMAGE im)
{
im = Kernels_use_DIY(im, KERNELS_Wave_Gauss, 9, 1.0 / 16);
return im;
}
效果和中值滤波差不多
4.2.3 低通滤波
低通滤波是增强图像的低频成分,阻挡高频成分,使图像变得平滑与模糊。
double KERNELS_Wave_LowPass_LP1[9] =
{
1 / 9.0, 1 / 9.0, 1 / 9.0,
1 / 9.0, 1 / 9.0, 1 / 9.0,
1 / 9.0, 1 / 9.0, 1 / 9.0
};
double KERNELS_Wave_LowPass_LP2[9] =
{
1 / 10.0, 1 / 10.0, 1 / 10.0,
1 / 10.0, 1 / 5.0, 1 / 10.0,
1 / 10.0, 1 / 10.0, 1 / 10.0
};
double KERNELS_Wave_LowPass_LP3[9] =
{
1 / 16.0, 1 / 8.0, 1 / 16.0,
1 / 8.0, 1 / 4.0, 1 / 8.0,
1 / 16.0, 1 / 8.0, 1 / 16.0
};
IMAGE Wavefiltering_LowPass(IMAGE im, double* kernels)
{
im = Kernels_use_DIY(im, kernels, 9, 1);
return im;
}
LP1 LP2 LP3
4.2.4 高通滤波
高通滤波是增强图像的高频成分,阻挡低频成分,使图像变得锐化与清晰。
double KERNELS_Wave_HighPass_HP1[9] =
{
-1, -1, -1,
-1, 9, -1,
-1, -1 ,-1
};
double KERNELS_Wave_HighPass_HP2[9] =
{
0, -1, 0,
-1, 5, -1,
0, -1 ,0
};
double KERNELS_Wave_HighPass_HP3[9] =
{
1, -2, 1,
-2, 5, -2,
1, -2 ,1
};
IMAGE Wavefiltering_HighPass(IMAGE im, double* kernels)
{
im = Kernels_use_DIY(im, kernels, 9, 1);
return im;
}
HP1 HP2 HP3
4.2.5 均值滤波
和低通滤波差不多,都是使图像变得平滑与模糊。
double KERNELS_Wave_Average[25] =
{
1, 1, 1, 1, 1,
1, 1, 1, 1, 1,
1, 1, 1, 1, 1,
1, 1, 1, 1, 1,
1, 1, 1, 1, 1
};
IMAGE Wavefiltering_Average(IMAGE im)
{
im = Kernels_use_DIY(im, KERNELS_Wave_Average, 25, 1.0 / 25);
return im;
}
4.3 边缘检测
边缘检测就是找出图像中像素亮度发生剧烈变化的区域,通常这些区域表现出就是图像的轮廓。
4.3.1 差分边缘检测
差分边缘检测有三个卷积核,分别用于检测水平方向,垂直方向,垂直和水平方向。
double KERNELS_Edge_difference_vertical[9] =
{
0, 0, 0,
-1, 1, 0,
0, 0, 0
};
double KERNELS_Edge_difference_horizontal[9] =
{
0,-1, 0,
0, 1, 0,
0, 0, 0
};
double KERNELS_Edge_difference_VH[9] =
{
-1, 0, 0,
0, 1, 0,
0, 0, 0
};
IMAGE Edge_detection_difference(IMAGE im, double* kernels)
{
im = Kernels_use_DIY(im, kernels, 9, 1);
return im;
}
差分垂直和水平边缘检测
4.3.2 Sobel边缘检测
差分边缘检测有两个卷积核,分别对应XY方向,而该点的像素值为
c
o
l
o
r
=
x
2
+
y
2
color=\sqrt{x^{2}+y^{2}}
color=x2+y2
?
double KERNELS_Edge_Sobel_X[9] =
{
-1, 0, 1,
- 2, 0, 2,
-1, 0, 1
};
double KERNELS_Edge_Sobel_Y[9] =
{
-1, -2, -1,
0, 0, 0,
1, 2, 1
};
IMAGE Kernels_use_Edge_Sobel(IMAGE im, double* kernels1, double* kernels2)
{
BGRA* bgra = (BGRA*)malloc(sizeof(BGRA) * im.w * im.h);
for (unsigned int i = 0; i < im.w * im.h; i++)
{
int p[9] =
{
i + im.w - 1,i + im.w,i + im.w + 1,
i - 1,i,i + 1,
i - im.w - 1,i - im.w,i - im.w + 1
};
for (int j = 0; j < 9; j++)
if (p[j] < 0 || p[j] >= im.w * im.h)
p[j] = i;
unsigned char color1 = Tool_RBG(im.color[p[0]].blue * kernels1[0] + im.color[p[1]].blue * kernels1[1] + im.color[p[2]].blue * kernels1[2] + im.color[p[3]].blue * kernels1[3] + im.color[p[4]].blue * kernels1[4] + im.color[p[5]].blue * kernels1[5] + im.color[p[6]].blue * kernels1[6] + im.color[p[7]].blue * kernels1[7] + im.color[p[8]].blue * kernels1[8]);
if (kernels2 != NULL)
{
unsigned char color2 = Tool_RBG(im.color[p[0]].blue * kernels2[0] + im.color[p[1]].blue * kernels2[1] + im.color[p[2]].blue * kernels2[2] + im.color[p[3]].blue * kernels2[3] + im.color[p[4]].blue * kernels2[4] + im.color[p[5]].blue * kernels2[5] + im.color[p[6]].blue * kernels2[6] + im.color[p[7]].blue * kernels2[7] + im.color[p[8]].blue * kernels2[8]);
color1 = Tool_RBG(sqrt(color1 * color1 + color2 * color2));
}
bgra[i].blue = color1;
bgra[i].green = color1;
bgra[i].red = color1;
}
free(im.color);
im.color = bgra;
return im;
}
X方向 Y方向 XY方向
4.3.3 Laplace边缘检测
Laplace边缘检测的最大特点是它的边缘检测与方向无关。
double KERNELS_Edge_Laplace_LAP1[9] =
{
0, 1, 0,
1, -4, 1,
0, 1, 0
};
double KERNELS_Edge_Laplace_LAP2[9] =
{
-1, -1, -1,
-1, 8, -1,
-1, -1, -1
};
double KERNELS_Edge_Laplace_LAP3[9] =
{
-1, -1, -1,
-1, 9, -1,
-1, -1, -1
};
double KERNELS_Edge_Laplace_LAP4[9] =
{
1, -2, 1,
-2, 8, -2,
1, -2, 1
};
IMAGE Edge_detection_Laplace(IMAGE im, double* kernels)
{
im = Kernels_use_DIY(im, kernels, 9, 1);
return im;
}
LAP1 LAP2 LAP3 LAP4
4.4 形态学图像处理
- 形态学图像处理包括,图形腐蚀,膨胀,开闭运算等,一般都是对二值图进行处理。且二值图是黑底白字(可通过反色转换)
- 形态学图像处理中的用的卷积核种类很多,可根据实际自己设计。
4.4.1 腐蚀
- 图像腐蚀是将图像中的高亮区域即白色部分进行缩减细化,其运行结果图比原图的高亮区域更小。即图像轮廓变细了。
- 腐蚀用的卷积核,用的是十字形
double KERNELS_Morphology_Erosion_cross[9] =
{
0, 1, 0,
1, 1, 1,
0, 1, 0
};
- 腐蚀卷积核的使用规则,除去卷积核的中心,将其它8个数与图像上对应的数相乘再相加,如果结果小于255乘卷积核中8个数的和的积,那么就将卷积核的中心所对应的图像上的数变为0 。(即边上四个1中有一个所对应的数是0就成立)
- 腐蚀用的公式
∑
n
=
1
8
k
n
?
p
n
<
255
?
∑
n
=
1
8
k
n
\sum_{n=1}^{8}kn\cdot pn<255\cdot\sum_{n=1}^{8}kn
n=1∑8?kn?pn<255?n=1∑8?kn kn是:卷积核中的数 pn是:卷积核所对应的图像上的数 注意:这八个数字不包含卷积核的中心
十字形腐蚀卷积核的公式
(
k
1
?
p
1
+
k
2
?
p
2
+
k
3
?
p
3
+
k
4
?
p
4
)
<
255
?
(
k
1
+
k
2
+
k
3
+
k
4
)
\left(k_{1}\cdot p_{1}+k_{2}\cdot p_{2}+k_{3}\cdot p_{3}+k_{4}\cdot p_{4}\right)<255\cdot\left(k_{1}+k_{2}+k_{3}+k_{4}\right)
(k1??p1?+k2??p2?+k3??p3?+k4??p4?)<255?(k1?+k2?+k3?+k4?) 注意:这里是方便读者理解,但实际代码实现中为了代码的通用性,是用原公式实现的。
IMAGE Morphology_Erosion(IMAGE im, double* kernels)
{
BGRA* bgra = (BGRA*)malloc(sizeof(BGRA) * im.w * im.h);
for (unsigned int i = 0; i < im.w * im.h; i++)
{
int p[9] =
{
i + im.w - 1,i + im.w,i + im.w + 1,
i - 1,i,i + 1,
i - im.w - 1,i - im.w,i - im.w + 1
};
for (int j = 0; j < 9; j++)
if (p[j] < 0 || p[j] >= im.w * im.h)
p[j] = i;
if ((im.color[i].blue == 255) && (im.color[p[0]].blue * kernels[0] + im.color[p[1]].blue * kernels[1] + im.color[p[2]].blue * kernels[2] + im.color[p[3]].blue * kernels[3] + im.color[p[5]].blue * kernels[5] + im.color[p[6]].blue * kernels[6] + im.color[p[7]].blue * kernels[7] + im.color[p[8]].blue * kernels[8]) < 255 * (kernels[0] + kernels[1] + kernels[2] + kernels[3] + kernels[5] + kernels[6] + kernels[7] + kernels[8]))
{
bgra[i].blue = 0;
bgra[i].green = 0;
bgra[i].red = 0;
}
else
{
bgra[i].blue = im.color[i].blue;
bgra[i].green = im.color[i].green;
bgra[i].red = im.color[i].red;
}
}
free(im.color);
im.color = bgra;
return im;
}
腐蚀前后效果比较
4.4.2 膨胀
- 图像膨胀是将图像中的高亮区域即白色部分进行扩张添加像素值,其运行结果图比原图的高亮区域更大。即图像轮廓变粗了。
- 膨胀用的卷积核与腐蚀用的一样,都是十字形。
double KERNELS_Morphology_Dilation_cross[9] =
{
0, 1, 0,
1, 1, 1,
0, 1, 0
};
- 膨胀卷积核的使用规则,除去卷积核的中心,将其它8个数与图像上对应的数相乘再相加,如果结果大于等于255。( 即边上四个1中有一个所对应的数是1就成立)
- 膨胀用的公式
∑
n
=
1
8
k
n
?
p
n
>
=
255
\sum_{n=1}^{8}kn\cdot pn>=255
n=1∑8?kn?pn>=255 kn是:卷积核中的数 pn是:卷积核所对应的图像上的数 注意:这八个数字不包含卷积核的中心
十字形膨胀卷积核的公式
(
k
1
?
p
1
+
k
2
?
p
2
+
k
3
?
p
3
+
k
4
?
p
4
)
>
=
255
\left(k_{1}\cdot p_{1}+k_{2}\cdot p_{2}+k_{3}\cdot p_{3}+k_{4}\cdot p_{4}\right)>=255
(k1??p1?+k2??p2?+k3??p3?+k4??p4?)>=255 注意:这里是方便读者理解,但实际代码实现中为了代码的通用性,是用原公式实现的。
IMAGE Morphology_Dilation(IMAGE im, double* kernels)
{
BGRA* bgra = (BGRA*)malloc(sizeof(BGRA) * im.w * im.h);
for (unsigned int i = 0; i < im.w * im.h; i++)
{
int p[9] =
{
i + im.w - 1,i + im.w,i + im.w + 1,
i - 1,i,i + 1,
i - im.w - 1,i - im.w,i - im.w + 1
};
for (int j = 0; j < 9; j++)
if (p[j] < 0 || p[j] >= im.w * im.h)
p[j] = i;
if ((im.color[i].blue == 0) && (im.color[p[0]].blue * kernels[0] + im.color[p[1]].blue * kernels[1] + im.color[p[2]].blue * kernels[2] + im.color[p[3]].blue * kernels[3] + im.color[p[5]].blue * kernels[5] + im.color[p[6]].blue * kernels[6] + im.color[p[7]].blue * kernels[7] + im.color[p[8]].blue * kernels[8]) >= 255)
{
bgra[i].blue = 255;
bgra[i].green = 255;
bgra[i].red = 255;
}
else
{
bgra[i].blue = im.color[i].blue;
bgra[i].green = im.color[i].green;
bgra[i].red = im.color[i].red;
}
}
free(im.color);
im.color = bgra;
return im;
}
4.4.3 开闭运算
1. 开运算:先腐蚀后膨胀 2. 闭运算:先膨胀后腐蚀 一般一张图片会经过多次的腐蚀和膨胀处理,但第一次是腐蚀操作还是膨胀操作,会起到决定性的用,要认真选择。
五. 池化
5.1 池化的介绍
1.池化操作可以进行数据压缩和参数压缩,减少过拟合提高所提取特征的鲁棒性,同时提高运算速度。池化操作一般分为三种,MAX,AVERAGE,MAX,其中MAX是最常用的。
5.2 MAX池化的原理
看图,一目了然
5.3 池化的实现
IMAGE Pooling(IMAGE im, int lenght)
{
unsigned int width = im.w / lenght;
unsigned int hight = im.h / lenght;
BGRA* bgra = (BGRA*)malloc(sizeof(BGRA) * width * hight);
int* p = (int*)malloc(sizeof(int) * lenght * lenght);
unsigned char maxColor = 0;
int k = 0;
for (unsigned int i = 0; i < im.w * im.h; i += lenght)
{
int t = 0;
for (int n = 0; n < lenght; n++)
for (int m = 0; m < lenght; m++)
p[t] = ((i % im.w) + m) + (i / im.w + n) * im.w, t++;
if (p[lenght * lenght - 1] >= im.w * im.h)
break;
else if (i / im.w != 0 && (i / im.w) % lenght != 0)
{
i += (lenght - 1) * im.w;
continue;
}
else if ((p[lenght * lenght - 1] / im.w) - (p[0] / im.w) + 1 != lenght)
{
i = i / im.w * im.w + im.w * lenght - lenght;
continue;
}
else
{
maxColor = im.color[p[0]].blue;
for (int j = 0; j < lenght * lenght; j++)
if (im.color[p[j]].blue > maxColor)
maxColor = im.color[p[j]].blue;
bgra[k].blue = maxColor, bgra[k].green = maxColor, bgra[k].red = maxColor, k++;
}
}
free(p);
free(im.color);
im.color = bgra;
im.w = width;
im.h = hight;
return im;
}
池化前后的比较 用的是
2
?
2
2*2
2?2的大小的卷积核,进行池化操作,处理后图片缩小为原图的
1
4
\frac{1}{4}
41?
五. VJ算法的介绍与分析
VJ(Viola-Jones)算法于2001年的CVPR上提出。该算法一般是被用来检测正面的人脸图像,而对于侧脸图像的检测能力较弱。即便如此它也是人脸检测算法中是非常经典的存在。作者实在能力有限,只是根据自己的理解去尽可能的实现,所以与真正的VJ算法,有较大不同。
六. 积分图
6.1 积分图的介绍
积分图是统计二值图中各个区域像素个数的矩阵数组,使用积分图可以非常快速的计算出任意矩形区域内的像素个数,极大的简化了计算。 积分图的统计规则是:统计原点到图上任意一点所组成的矩形区域中的像素个数,所以积分图数组的大小等于图片的象数个数。如图中的(
a
=
>
b
a=>b
a=>b)区域,(
a
=
>
c
a=>c
a=>c)区域,(
a
=
>
f
a=>f
a=>f)区域,(
a
=
>
e
a=>e
a=>e)区域,(
a
=
>
d
a=>d
a=>d)区域。
计算(
f
=
>
d
f=>d
f=>d)区域中的像素个数
(
f
=
>
d
)
=
(
a
=
>
d
)
?
(
a
=
>
c
)
?
(
a
=
>
e
)
+
(
a
=
>
b
)
(f=>d)=(a=>d)-(a=>c)-(a=>e)+(a=>b)
(f=>d)=(a=>d)?(a=>c)?(a=>e)+(a=>b)
6.2 积分图的生成
- 图像在经过卷积操作和二值化操作后,会变成“黑底白字”,但为了方便人眼观察和积分图处理,会进行反色处理使其变成“白底黑字”。
- 下面这段代码很巧妙,注意理解。
typedef struct tagIGIMAGE
{
unsigned int w;
unsigned int h;
int* date;
}IGIMAGE, *PIGIMAGE;
IGIMAGE IntegralImage_get(IMAGE im)
{
IGIMAGE IGmap;
int* array = (int*)malloc(sizeof(int) * im.w * im.h);
int k = 0;
for (unsigned int i = 0; i < im.w * im.h; i++)
{
if (i % im.w == 0)
k = 0;
if (im.color[i].blue == 0)
k++;
if (i / im.w == 0)
array[i] = k;
else
array[i] = array[i - im.w] + k;
}
IGmap.date = array;
IGmap.w = im.w;
IGmap.h = im.h;
return IGmap;
}
6.3 积分图的计算
int IntegralImage_count(IGIMAGE IGmap, int rightTop, int leftBottom)
{
int a1, a2, a3, a4;
a1 = leftBottom;
a2 = (rightTop % IGmap.w) + (leftBottom / IGmap.w ) * IGmap.w;
a3 = (leftBottom % IGmap.w) + (rightTop / IGmap.w) * IGmap.w;
a4 = rightTop;
if (a1 < 0)
a1 = 0;
if (a2 < 0)
a2 = 0;
if (a3 < 0)
a3 = 0;
if (a3 > IGmap.w * IGmap.h - 1)
a3 = a4;
return IGmap.date[a4] - IGmap.date[a3] - IGmap.date[a2] + IGmap.date[a1];
}
void IntegralImage_free(IGIMAGE IGimage)
{
free(IGimage.date);
}
七. 类哈尔特征
6.1 哈尔特征的介绍
哈尔特征(Haar-like features) 是用于物体识别的一种数字图像特征。哈尔特征模板内有白色和黑色两种矩形,其反映了图像的亮暗变化情况。即人脸图像不同区域的像素分部情况。例如,眼睛和眉毛区域的像素多且密集,所以是黑色矩形,脸颊区域的像素少且稀疏,所以是白色矩形。
6.2 类哈尔特征的选取
作者在这项目中并没有严格按哈尔特征模板的定义去识别图片,而是自己定义了一些类似于哈尔特征的模板去简化操作的流程。会在下文单分支决策树分类器的构建中详细讲解。
七. 级联分类器(单分支决策树分类器)
7.2 单分支决策树分类器的介绍
简单的讲就是一连串的if判断语句,快速过滤掉不是人脸的宽口,最后满足所有if条件的即为人脸。只能用正态分布的去简单的计算正确的概率。
7.3 单分支决策树分类器的构建
作者将要检测的人脸的区域分成25份,根据人脸不同部位的像素分布,制定相应的规则,很简单就不过多讲解了,请自行看代码。(读者也可自己添加)
double Classifier_decisionStump(IGIMAGE IGmap, int rightTop, int leftBottom)
{
int areaW = (rightTop % IGmap.w) - (leftBottom % IGmap.w);
int areaH = (rightTop / IGmap.w) - (leftBottom / IGmap.w);
int x0 = leftBottom % IGmap.w;
int y0 = leftBottom / IGmap.w;
int x1 = rightTop % IGmap.w;
int y1 = rightTop / IGmap.w;
int w_all = IntegralImage_count(IGmap, rightTop, leftBottom);
int w_1 = IntegralImage_count(IGmap, (x1 - areaW * 4 / 5) + y1 * IGmap.w, x0 + (y1 - areaH / 5) * IGmap.w);
int w_2 = IntegralImage_count(IGmap, (x1 - areaW * 3 / 5) + y1 * IGmap.w, (x1 - areaW * 4 / 5) + (y1 - areaH / 5) * IGmap.w);
int w_3 = IntegralImage_count(IGmap, (x1 - areaW * 2 / 5) + y1 * IGmap.w, (x1 - areaW * 3 / 5) + (y1 - areaH / 5) * IGmap.w);
int w_4 = IntegralImage_count(IGmap, (x1 - areaW / 5) + y1 * IGmap.w, (x1 - areaW * 2 / 5) + (y1 - areaH / 5) * IGmap.w);
int w_5 = IntegralImage_count(IGmap, x1 + y1 * IGmap.w, (x1 - areaW * 1 / 5) + (y1 - areaH / 5) * IGmap.w);
int w_6 = IntegralImage_count(IGmap, (x1 - areaW * 4 / 5) + (y1 - areaH / 5) * IGmap.w, x0 + (y1 - areaH * 2 / 5) * IGmap.w);
int w_7 = IntegralImage_count(IGmap, (x1 - areaW * 3 / 5) + (y1 - areaH / 5) * IGmap.w, (x1 - areaW * 4 / 5) + (y1 - areaH * 2 / 5) * IGmap.w);
int w_8 = IntegralImage_count(IGmap, (x1 - areaW * 2 / 5) + (y1 - areaH / 5) * IGmap.w, (x1 - areaW * 3 / 5) + (y1 - areaH * 2 / 5) * IGmap.w);
int w_9 = IntegralImage_count(IGmap, (x1 - areaW / 5) + (y1 - areaH / 5) * IGmap.w, (x1 - areaW * 2 / 5) + (y1 - areaH * 2 / 5) * IGmap.w);
int w_10 = IntegralImage_count(IGmap, x1 + (y1 - areaH / 5) * IGmap.w, (x1 - areaW / 5) + (y1 - areaH * 2 / 5) * IGmap.w);
int w_11 = IntegralImage_count(IGmap, (x1 - areaW * 4 / 5) + (y1 - areaH * 2 / 5) * IGmap.w, x0 + (y1 - areaH * 3 / 5) * IGmap.w);
int w_12 = IntegralImage_count(IGmap, (x1 - areaW * 3 / 5) + (y1 - areaH * 2 / 5) * IGmap.w, (x1 - areaW * 4 / 5) + (y1 - areaH * 3 / 5) * IGmap.w);
int w_13 = IntegralImage_count(IGmap, (x1 - areaW * 2 / 5) + (y1 - areaH * 2 / 5) * IGmap.w, (x1 - areaW * 3 / 5) + (y1 - areaH * 3 / 5) * IGmap.w);
int w_14 = IntegralImage_count(IGmap, (x1 - areaW / 5) + (y1 - areaH * 2 / 5) * IGmap.w, (x1 - areaW * 2 / 5) + (y1 - areaH * 3 / 5) * IGmap.w);
int w_15 = IntegralImage_count(IGmap, x1 + (y1 - areaH * 2 / 5) * IGmap.w, (x1 - areaW / 5) + (y1 - areaH * 3 / 5) * IGmap.w);
int w_16 = IntegralImage_count(IGmap, (x1 - areaW * 4 / 5) + (y1 - areaH * 3 / 5) * IGmap.w, x0 + (y1 - areaH * 4 / 5) * IGmap.w);
int w_17 = IntegralImage_count(IGmap, (x1 - areaW * 3 / 5) + (y1 - areaH * 3 / 5) * IGmap.w, (x1 - areaW * 4 / 5) + (y1 - areaH * 4 / 5) * IGmap.w);
int w_18 = IntegralImage_count(IGmap, (x1 - areaW * 2 / 5) + (y1 - areaH * 3 / 5) * IGmap.w, (x1 - areaW * 3 / 5) + (y1 - areaH * 4 / 5) * IGmap.w);
int w_19 = IntegralImage_count(IGmap, (x1 - areaW / 5) + (y1 - areaH * 3 / 5) * IGmap.w, (x1 - areaW * 2 / 5) + (y1 - areaH * 4 / 5) * IGmap.w);
int w_20 = IntegralImage_count(IGmap, x1 + (y1 - areaH * 3 / 5) * IGmap.w, (x1 - areaW / 5) + (y1 - areaH * 4 / 5) * IGmap.w);
int w_21 = IntegralImage_count(IGmap, (x1 - areaW * 4 / 5) + (y1 - areaH * 4 / 5) * IGmap.w, x0 + y0 * IGmap.w);
int w_22 = IntegralImage_count(IGmap, (x1 - areaW * 3 / 5) + (y1 - areaH * 4 / 5) * IGmap.w, (x1 - areaW * 4 / 5) + y0 * IGmap.w);
int w_23 = IntegralImage_count(IGmap, (x1 - areaW * 2 / 5) + (y1 - areaH * 4 / 5) * IGmap.w, (x1 - areaW * 3 / 5) + y0 * IGmap.w);
int w_24 = IntegralImage_count(IGmap, (x1 - areaW / 5) + (y1 - areaH * 4 / 5) * IGmap.w, (x1 - areaW * 2 / 5) + y0 * IGmap.w);
int w_25 = IntegralImage_count(IGmap, x1 + (y1 - areaH * 4 / 5) * IGmap.w, (x1 - areaW / 5) + y0 * IGmap.w);
if ((double)w_all / (areaW * areaH) < 0.19)
return 1;
if ((double)(w_1 + w_2 + w_6 + w_7) / (w_3 + w_8) < 2.6 || (double)(w_4 + w_5 + w_9 + w_10) / (w_3 + w_8) < 2.6)
return 1;
if ((double)(w_13 + w_18) / (w_11 + w_16 ) < 1 || (double)(w_13 + w_18) / ( w_15 + w_20) < 1)
return 1;
if ((double)(w_1 + w_2 + w_6 + w_7) / (w_11 + w_12 + w_16 + w_17) < 1.3 || (double)(w_4 + w_5 + w_9 + w_10) / (w_14 + w_15 + w_19 + w_20) < 1.3)
return 1;
if ((double)(w_1 + w_2 + w_3 + w_4 + w_5 + w_6 + w_7 + w_8 + w_9 + w_10) / (w_16 + w_17 + w_18 + w_19 + w_20 + w_21 + w_22 + w_23 + w_24 + w_25) > 2)
return 1;
if ((double)(w_1 + w_2 + w_6 + w_7 + w_4 + w_5 + w_9 + w_10 + w_13 + w_17 + w_18 + w_19 + w_23) / w_all < 0.6)
return 1;
double PCT_1 = (double)min(w_1 + w_2 + w_6 + w_7 + w_11 + w_12 + w_16 + w_17 + w_21 + w_22, w_4 + w_5 + w_9 + w_10 + w_14 + w_15 + w_19 + w_20 + w_24 + w_25) / max(w_1 + w_2 + w_6 + w_7 + w_11 + w_12 + w_16 + w_17 + w_21 + w_22, w_4 + w_5 + w_9 + w_10 + w_14 + w_15 + w_19 + w_20 + w_24 + w_25);
PCT_1 = exp(-3.125 * (PCT_1 - 1) * (PCT_1 - 1)) * 100;
double PCT_2 = (double)min(w_1 + w_2 + w_6 + w_7, w_4 + w_5 + w_9 + w_10) / max(w_1 + w_2 + w_6 + w_7, w_4 + w_5 + w_9 + w_10);
PCT_2 = exp(-3.125 * (PCT_1 - 1) * (PCT_1 - 1)) * 100;
double PCT_3 = (double)min(w_16 + w_21, w_20 + w_25) / max(w_16 + w_21, w_20 + w_25);
PCT_3 = exp(-3.125 * (PCT_3 - 1) * (PCT_3 - 1)) * 100;
double PCT_all = (PCT_1 + PCT_2 + PCT_3) / 3;
if (PCT_all > 60)
return PCT_all;
}
八. 多尺寸滑动窗口技术
8.1 多尺寸滑动窗口技术的介绍
因为人脸可能出现在图片中的任何位置,所以我们用不同大小的滑动窗口以等距步长在整幅图像上滑动,并对每一个滑动窗口做人脸检测。
8.2 多尺寸滑动窗口技术的实现
typedef struct tagFACEDATE
{
int rightTop;
int leftBottom;
double confidence;
}FACEDATE;
FACEDATE MoveWindowArea(IMAGE im, IGIMAGE IGmap)
{
FACEDATE maxFaceDate = { 0 };
double confidence = 0;
int minSide = min(im.w, im.h) / 3;
int daltaSide = 5;
int k = 0;
for (int i = 0; i <= (min(im.w, im.h) - minSide) / daltaSide - 1; i++)
{
int rightTop = (minSide + i * daltaSide) * (im.w + 1);
int leftBottom = 0;
while (rightTop != im.w * im.h - 1)
{
if ((rightTop + 1) % im.w == 0)
{
rightTop += minSide + i * daltaSide;
leftBottom += minSide + i * daltaSide;
}
else
{
rightTop += 1;
leftBottom += 1;
}
if ((confidence = Classifier_decisionStump(IGmap, rightTop, leftBottom)) > 1 && confidence > maxFaceDate.confidence)
{
maxFaceDate.confidence = confidence;
maxFaceDate.rightTop = rightTop;
maxFaceDate.leftBottom = leftBottom;
}
}
}
return maxFaceDate;
}
九. 运行项目
9.1 画出人脸框
void Image_draw(IMAGE im ,FACEDATE faceDate)
{
for (unsigned int i = faceDate.leftBottom / im.w; i <= faceDate.rightTop / im.w; i++) {
for (unsigned int j = faceDate.leftBottom % im.w; j <= faceDate.rightTop % im.w; j++) {
if (i == faceDate.leftBottom / im.w || i == faceDate.rightTop / im.w || j == faceDate.leftBottom % im.w || j == faceDate.rightTop % im.w) {
im.color[j + i * im.w].blue = 0;
im.color[j + i * im.w].green = 0;
im.color[j + i * im.w].red = 200;
}
}
}
}
9.2 项目运行结果
int main()
{
char loadFilename[300] = "C://Users//asus//Desktop//Image02//ren4.bmp";
char saveFilename[300] = "C://Users//asus//Desktop//Image02//ren4.bmp";
IMAGE image1 = Image_load(loadFilename);
IMAGE image2= Image_load(loadFilename);
Transform_color_grayscale(image1, GRAY_MODE_WEIGHT);
image1 = Wavefiltering_Average(image1);
image1 = Transform_color_BW_Adaptive(image1, 25);
IGIMAGE IGmap1 = IntegralImage_get(image1);
FACEDATE faceDate1 = MoveWindowArea(image1, IGmap1);
Image_draw(image2, faceDate1);
Image_save(saveFilename, image2);
IntegralImage_free(IGmap1);
Image_free(image1);
Image_free(image2);
Image_show(saveFilename);
return 0;
}
十. 项目中遇到的问题
10.1 malloc也有初始化功能?
BGRA* bgra = (BGRA*)malloc(sizeof(BGRA) * 100 * 100);
for(int i=0; i<100* 100; i++)
{
printf("%x ",bgra[i].blue);
printf("%x ",bgra[i].green);
printf("%x ",bgra[i].red);
printf("%x\n",bgra[i].transparency);
}
10.2 池化操作具体怎么使用?
作者认为池化操作与VJ算法的契合度并没有那么高,池化后的图片特征变的明显,但坐标被压缩,在池化后的图片中找寻人脸更加简单,但后续计算坐标困难(项目中并未使用)。
10.3 二值图的自适应阈值法也有边缘检测的功能?
作者在项目运行结果的代码中边缘检测用的是二值图的自适应阈值法,其效果和精度都好于边缘检测算法。
10.4 类哈尔特征怎么定义和计算?
文章中类哈尔特征的选取和计算,花费了作者大量的时间和精力,更改也及其麻烦。这也直接影响到最终人脸检测的正确度。
十一. 项目总结
- 由于个人能力有限,所以对一些相关算法的实现,进行了一定程度上的修改,代码中也有许多警告,但运行是没为问题的。
- 项目整体的人脸识别准确率和效率都不高。
十二. 结语
- 再简单的算法用数学去描述都是复杂的,作者的数学学不好,所以没有写出具体算法的数学原理和推导过程。
- 学得越多越知道自己知识有多匮乏,这篇文章的内容也越写越多,但受限与篇幅和作者的能力,每个知识点只能以这种简短的形式呈现。
- 代码进行了多次迭代,写博客的过程中也对其进行了三次重构(但人也没了),大幅提高了代码的可读性,可扩展性,和可维护性。
- 所有代码都在这篇文章中了,完整源码惨不忍睹就不拿出来献丑了。
不知道有多少人会看到这篇文章,也不知道有多少人会看到这里。最后再说几句吧。其实写这篇文章主要是写给自己看的,知识点有点多,怕自己忘记。所以一股脑整理了这一路下来的所学的所有东西。但一直坚持走下来真的很难,特别是学到一半发现要从头再来一遍的时候。。。。。。。。 好了就写这么多吧,最后希望这篇文章能帮助到你。
作者:墨尘_MO 时间:2021年10月3日
|