作业要求:
1、输入:有一张写着数字的A4纸的图片(如下)
2、A4纸矫正
3、数字字符切割
4、用Adaboost或SVM训练一个手写数字分类器
5、识别并输出:连串数字,如“13924579693”与“02087836761”等
实现环境:
Windows10 + VS2015 + cimg库 (+ opencv (用于svm预测时特征提取) + libsvm (用于训练/测试模型与预测))
由于此次作业量比较大,所以会分为上下两部分讲述。一部分为把字符切割出来,另一部分为SVM的训练和识别。此博文主要讲述到数字字符切割的一系列操作。
完整代码可以到我的github上查看:https://github.com/MarkMoHR/HandwritingNumberClassification
阶段结果:
实现步骤(仅到数字字符切割阶段):
1、A4纸边缘顶点提取
2、A4纸矫正
3、数字按顺序分割
3.1) 图像二值化
3.2) 基于垂直方向直方图,把原图进行行分割为多张行子图,每张行子图包含一行数字(可能有多列)
3.3) 基于水平方向直方图,把行子图进行列分割为多张真子图,每张真子图包含单行单列的数字
3.4) 对每张子图,进行扩张(dilation),并进行断裂字符修复
3.5) 对每张子图,用连通区域标记方法(connected-component_labeling algorithm)从左到右分割数字
3.6) 对每张子图,存储单个数字以及一个图像名列表文本
具体实现:
1、A4纸边缘顶点提取:
A4纸边缘与顶点提取在前面的博文也有写过,在这里就不再说了。
[传送门]:[计算机视觉] A4纸边缘检测
阶段结果:
2、A4纸矫正:
A4纸矫正也是之前的作业,记得貌似没写成博文。但是就一个公式的转换,没其他了,所以在这里也不打算细说。详细可到我的github上参考代码~
阶段结果:
3、数字按顺序分割
3.1、图像二值化:
1) 图像二值化之前,需要先将矫正后的图像转化为灰度图。
2) 图像二值化,就是将得到的灰度图,转化为只有灰度为0和255的图像。图像二值化有多种方法,比如全局阈值分割、局部阈值分割等。我这里使用了全局阈值分割,简单、速度快、效果不坏哈哈:) 。顾名思义,就是将小于某阈值的像素点像素设为0,其余的设为255。因此找到适合的阈值很关键,我根据几张图片的效果,选择阈值为135。可以发现上面的矫正后的图,会有边缘上的黑边影响,于是可以把靠近边缘的一些像素点都设为白色像素点。
阶段结果:
3.2、基于垂直方向直方图,把原图进行行分割为多张子图,每张子图包含一行数字:
1) 与上一个版本相比,现在在做扩张(dilation)之前做直方图行分割,原因是先做扩张可能使两行数字在原来相隔的地方连起来,接下来就不好做行分割了。
2) 为什么需要做行分割,而不是直接用连通区域标记方法做分割就好了?之前我在做的时候,也是直接做下面第3.4、3.5步,分割数字字符。但是由于连通区域标记算法必须从上到下或从左到右扫描,直接做的话结果是,得到分割出来的数字的顺序是乱的!从上到下扫描的话,顺序是越高的数字排越前;从左到右扫描的话,顺序是越左的数字排越前。但是我们需要的结果是一行一行的从左到右按顺序数字,不管是什么样的扫描顺序,对整张图一次性做的话,都得不到我们想要的结果。
3) 鉴于上述原因,我们需要先把数字一行行的先分割出来。基于垂直方向直方图的方法就是,可以想象到,如果我们做竖直方向的灰度直方图,就会出现波和谷。我们只需要在谷做一条分割线即可。找分割线的方法是,我们得先找到由黑转白和由白转黑的拐点,两拐点中间就是分割点。
4) 这样做分割之后,会出现下面这样一种情况,即白色之中有一个黑点都视为峰,这明显是一些断裂的点造成的,而这些点也显然可以忽略(不影响大的数字),也就是其所在的子图无意义,可抛弃。显然一种处理的方法是:统计子图的黑色像素个数,只有超过整张子图大小一定比例才可视为该子图存在完整数字,同时更新割线;否则抛弃。
void ImageSegmentation::findDividingLine() {
HistogramImage = CImg(BinaryImg._width, BinaryImg._height, 1, 3, 0);
DividingImg = CImg(BinaryImg._width, BinaryImg._height, 1, 3, 0);
int lineColor[3]{ 255, 0, 0 };
cimg_forY(HistogramImage, y) {
int blackPixel = 0;
cimg_forX(BinaryImg, x) {
HistogramImage(x, y, 0) = 255;
HistogramImage(x, y, 1) = 255;
HistogramImage(x, y, 2) = 255;
DividingImg(x, y, 0) = BinaryImg(x, y, 0);
DividingImg(x, y, 1) = BinaryImg(x, y, 0);
DividingImg(x, y, 2) = BinaryImg(x, y, 0);
if (BinaryImg(x, y, 0) == 0)
blackPixel++;
}
cimg_forX(HistogramImage, x) {
if (x < blackPixel) {
HistogramImage(x, y, 0) = 0;
HistogramImage(x, y, 1) = 0;
HistogramImage(x, y, 2) = 0;
}
}
//判断是否为拐点
if (y > 0) {
if (blackPixel <= HistogramValleyMaxPixelNumber
&& HistogramImage(HistogramValleyMaxPixelNumber, y - 1, 0) == 0) { //下白上黑:取下
inflectionPointSet.push_back(y);
//HistogramImage.draw_line(0, y, HistogramImage._width - 1, y, lineColor);
}
else if (blackPixel > HistogramValleyMaxPixelNumber
&& HistogramImage(HistogramValleyMaxPixelNumber, y - 1, 0) != 0) { //下黑上白:取上
inflectionPointSet.push_back(y - 1);
//HistogramImage.draw_line(0, y - 1, HistogramImage._width - 1, y - 1, lineColor);
}
}
}
divideLinePointSet.push_back(-1);
//两拐点中间做分割
if (inflectionPointSet.size() > 2) {
for (int i = 1; i < inflectionPointSet.size() - 1; i = i + 2) {
int divideLinePoint = (inflectionPointSet[i] + inflectionPointSet[i + 1]) / 2;
divideLinePointSet.push_back(divideLinePoint);
}
}
divideLinePointSet.push_back(BinaryImg._height - 1);
}
void ImageSegmentation::divideIntoBarItemImg() {
vector newDivideLinePointSet;
int lineColor[3]{ 255, 0, 0 };
for (int i = 1; i < divideLinePointSet.size(); i++) {
int barHright = divideLinePointSet[i] - divideLinePointSet[i - 1];
int blackPixel = 0;
CImg barItemImg = CImg(BinaryImg._width, barHright, 1, 1, 0);
cimg_forXY(barItemImg, x, y) {
barItemImg(x, y, 0) = BinaryImg(x, divideLinePointSet[i - 1] + 1 + y, 0);
if (barItemImg(x, y, 0) == 0)
blackPixel++;
}
double blackPercent = (double)blackPixel / (double)(BinaryImg._width * barHright);
cout << "blackPercent " << blackPercent << endl;
if (blackPercent > SubImgBlackPixelPercentage) {
subImageSet.push_back(barItemImg);
newDivideLinePointSet.push_back(divideLinePointSet[i - 1]);
//barItemImg.display("barItemImg");
if (i > 1) {
HistogramImage.draw_line(0, divideLinePointSet[i - 1],
HistogramImage._width - 1, divideLinePointSet[i - 1], lineColor);
DividingImg.draw_line(0, divideLinePointSet[i - 1],
HistogramImage._width - 1, divideLinePointSet[i - 1], lineColor);
}
}
}
divideLinePointSet.clear();
for (int i = 0; i < newDivideLinePointSet.size(); i++)
divideLinePointSet.push_back(newDivideLinePointSet[i]);
}
阶段结果:
3.3、基于水平方向直方图,把行子图进行列分割为多张真子图,每张真子图包含单行单列的数字:
1) 从上面行分割得到的图像,有可能是多列的数字(如下图)。
2) 显然,我们会想到,利用上面使用过的直方图的方法,只不过改为水平的,不就好了吗。然而做了直方图后,发现与垂直方向的直方图还是有挺大差别的(如下图)。我们能看到,水平方向直方图会出现很多峰,而这是跟我们阿拉伯数字的书写方式有关,即两个数字之间有间隔!
3) 那怎么使靠近的各组数字分隔开来呢?我一开始想了用固定间距阈值,即两个峰之间间隔大于一定阈值的时候,视为中间需要隔开。但是固定阈值不适用于所有图片,毕竟可能存在两组数字之间间隔不大,但也能明显区分为两组数字的情况。于是我想出了一种动态间距阈值的方法:计算所有峰之间的间距的均值,只有当间距大于均值的一定倍数时,才视为要隔开。这种方法的好处是把同组数字之间的间隔也考虑进去了,因为我们可以明显看到,两组数字之间的间距大小,与同组数字间的间隔有关。
//根据X方向直方图判断真实的拐点
vector getInflectionPosXs(const CImg& XHistogramImage) {
vector resultInflectionPosXs;
vector tempInflectionPosXs;
int totalDist = 0, avgDist;
int distNum = 0;
//查找拐点
cimg_forX(XHistogramImage, x) {
if (x >= 1) {
//白转黑
if (XHistogramImage(x, 0, 0) == 0 && XHistogramImage(x - 1, 0, 0) == 255) {
tempInflectionPosXs.push_back(x - 1);
}
//黑转白
else if (XHistogramImage(x, 0, 0) == 255 && XHistogramImage(x - 1, 0, 0) == 0) {
tempInflectionPosXs.push_back(x);
}
}
}
for (int i = 2; i < tempInflectionPosXs.size() - 1; i = i + 2) {
int dist = tempInflectionPosXs[i] - tempInflectionPosXs[i - 1];
if (dist <= 0)
distNum--;
totalDist += dist;
}
//计算间距平均距离
distNum += (tempInflectionPosXs.size() - 2) / 2;
avgDist = totalDist / distNum;
//cout << "avgDist " << avgDist << endl;
resultInflectionPosXs.push_back(tempInflectionPosXs[0]); //头
//当某个间距大于平均距离的一定倍数时,视为分割点所在间距
for (int i = 2; i < tempInflectionPosXs.size() - 1; i = i + 2) {
int dist = tempInflectionPosXs[i] - tempInflectionPosXs[i - 1];
//cout << "dist " << dist << endl;
if (dist > avgDist * XHistogramValleyMaxPixelNumber) {
resultInflectionPosXs.push_back(tempInflectionPosXs[i - 1]);
resultInflectionPosXs.push_back(tempInflectionPosXs[i]);
}
}
resultInflectionPosXs.push_back(tempInflectionPosXs[tempInflectionPosXs.size() - 1]); //尾
return resultInflectionPosXs;
}
//获取一行行的子图的水平分割线
vector getDivideLineXofSubImage(const CImg& subImg) {
vector InflectionPosXs;
//先绘制X方向灰度直方图
CImg XHistogramImage = CImg(subImg._width, subImg._height, 1, 3, 0);
cimg_forX(subImg, x) {
int blackPixel = 0;
cimg_forY(subImg, y) {
XHistogramImage(x, y, 0) = 255;
XHistogramImage(x, y, 1) = 255;
XHistogramImage(x, y, 2) = 255;
if (subImg(x, y, 0) == 0)
blackPixel++;
}
//对于每一列x,只有黑色像素多于一定值,才绘制在直方图上
if (blackPixel >= XHistogramValleyMaxPixelNumber) {
cimg_forY(subImg, y) {
if (y < blackPixel) {
XHistogramImage(x, y, 0) = 0;
XHistogramImage(x, y, 1) = 0;
XHistogramImage(x, y, 2) = 0;
}
}
}
}
InflectionPosXs = getInflectionPosXs(XHistogramImage); //获取拐点
cout << "InflectionPosXs.size() " << InflectionPosXs.size() << endl;
for (int i = 0; i < InflectionPosXs.size(); i++)
XHistogramImage.draw_line(InflectionPosXs[i], 0, InflectionPosXs[i], XHistogramImage._height - 1, lineColor);
//XHistogramImage.display("XHistogramImage");
//两拐点中间做分割
vector dividePosXs;
dividePosXs.push_back(-1);
if (InflectionPosXs.size() > 2) {
for (int i = 1; i < InflectionPosXs.size() - 1; i = i + 2) {
int divideLinePointX = (InflectionPosXs[i] + InflectionPosXs[i + 1]) / 2;
dividePosXs.push_back(divideLinePointX);
}
}
dividePosXs.push_back(XHistogramImage._width - 1);
return dividePosXs;
}
//分割行子图,得到列子图
//@_dividePosXset 以-1起,以lineImg._width结束
vector> getRowItemImgSet(const CImg& lineImg, vector _dividePosXset) {
vector> result;
for (int i = 1; i < _dividePosXset.size(); i++) {
int rowItemWidth = _dividePosXset[i] - _dividePosXset[i - 1];
CImg rowItemImg = CImg(rowItemWidth, lineImg._height, 1, 1, 0);
cimg_forXY(rowItemImg, x, y) {
rowItemImg(x, y, 0) = lineImg(x + _dividePosXset[i - 1] + 1, y, 0);
}
result.push_back(rowItemImg);
}
return result;
}
3.4、对每张子图,进行扩张(dilation),并进行断裂字符修复:
1) 利用扩张进行字符修复:做二值化的时候,阈值取太小,有些数字的像素点被视为白点,容易造成字符断裂;阈值取太大,很多由于阴影产生的噪声点又会混进来。所以取恰当的阈值很重要。但是不管取什么阈值,都有可能出现字符断裂的情况(如下图)。扩张(Dilation)就是解决字符断裂的一种方法,这是数字图像处理上学到的一种方法,解释起来也比较复杂,可以参考:https://en.wikipedia.org/wiki/Dilation_(morphology) 简单的说就是当前点是0还是255还要根据周围的像素点来判断。
2) 我用了以下两个滤波器来进行扩张与断裂字符修复:先用filterA做2次滤波,再用filterB做1次滤波(注意使用的次数以及顺序!)
3) filterB作用是:当前位置为白色像素时,检测上下左右的像素,若为黑色,则把自身设为黑色。
4) filterA作用是:当前位置为白色像素时,检测上/下1个单位像素,与左/右2个单位像素,统计黑色像素的总统计数。1为黑色像素个数加1,-1为黑色像素个数减1,只有当最后黑色像素的总统计数大于0,才把自身设为黑色。
5) 明显,filterB使数字往4个方向变厚,但这很可能导致的结果是,像0、6、8、9这几个数字中间的洞被填充成黑色。所以我提出filterA来解决这个问题,可以看到使用filterA,像素的灰度(黑or白)与当前位置的水平邻居关系很大,即遇到类似洞的位置能够尽可能防止被填充成黑色。
void ImageSegmentation::doDilationForEachBarItemImg(int barItemIndex) {
//扩张Dilation -X-X-X-XYY方向
CImg answerXXY = CImg(subImageSet[barItemIndex]._width, subImageSet[barItemIndex]._height, 1, 1, 0);
cimg_forXY(subImageSet[barItemIndex], x, y) {
int intensity = getDilationIntensityXXY(subImageSet[barItemIndex], x, y);
answerXXY(x, y, 0) = intensity;
}
//扩张Dilation -X-X-X-XYY方向
CImg answerXXY2 = CImg(answerXXY._width, answerXXY._height, 1, 1, 0);
cimg_forXY(answerXXY, x, y) {
int intensity = getDilationIntensityXXY(answerXXY, x, y);
answerXXY2(x, y, 0) = intensity;
}
//扩张Dilation XY方向
CImg answerXY = CImg(answerXXY2._width, answerXXY2._height, 1, 1, 0);
cimg_forXY(answerXXY2, x, y) {
int intensity = getDilationIntensityXY(answerXXY2, x, y);
answerXY(x, y, 0) = intensity;
}
cimg_forXY(subImageSet[barItemIndex], x, y) {
subImageSet[barItemIndex](x, y, 0) = answerXY(x, y, 0);
}
}
3.5、对每张子图,用连通区域标记方法(connected-component_labeling algorithm)从左到右分割数字:
1) 连通区域标记,从字面上很好理解,毕竟一个数字本身就是一个连通区域。而这种方法的实现也有很多种算法,比如二次扫描法、双向反复扫描法、区域增长法。
2) 我这里使用了速度相对较快的二次扫描法。下面利用一些图结合帮助理解算法的实现:
① 扫描图像第一列和第一行,每个黑色点作为一类,做上标记。每一类各个点的坐标用一个链表存储起来,每个链表的首地址存储在一个容器下,容器的下标刚好对应类的标记:
②一列一列进行遍历,遇到黑色点,即当前红点所在位置,检测其正上、左上、左前、左下4个位置,如下
③ 找到上述4个邻点的最小类标记(当前即是0),对其余标记的黑色点(即1和2),在链表容器里面找到对应的链表,然后接到刚找到的最小标记的链表上,接着其标记全改为刚才的最小标记。最后红点的坐标也加到最小标记的链表,红点也标记为最小标记。即变成如下:
④ 如果当前黑点的4个邻点都没有黑色点,则自身作为新类,加上新的标记,在链表容器里面加入新的链表,存储自己的坐标位置
⑤ 最后根据链表容器,同一个链表的即为同一类,即代表为同一个数字,然后可以根据链表的坐标提取单个数字。
void ImageSegmentation::connectedRegionsTaggingOfBarItemImg(int barItemIndex) {
TagImage = CImg(subImageSet[barItemIndex]._width, subImageSet[barItemIndex]._height, 1, 1, 0);
tagAccumulate = -1;
cimg_forX(subImageSet[barItemIndex], x)
cimg_forY(subImageSet[barItemIndex], y) {
//第一行和第一列
if (x == 0 || y == 0) {
int intensity = subImageSet[barItemIndex](x, y, 0);
if (intensity == 0) {
addNewClass(x, y, barItemIndex);
}
}
//其余的行和列
else {
int intensity = subImageSet[barItemIndex](x, y, 0);
if (intensity == 0) {
//检查正上、左上、左中、左下这四个邻点
int minTag = Infinite; //最小的tag
PointPos minTagPointPos(-1, -1);
//先找最小的标记
findMinTag(x, y, minTag, minTagPointPos, barItemIndex);
//当正上、左上、左中、左下这四个邻点有黑色点时,合并;
if (minTagPointPos.x != -1 && minTagPointPos.y != -1) {
mergeTagImageAndList(x, y - 1, minTag, minTagPointPos, barItemIndex);
for (int i = -1; i <= 1; i++) {
if (y + i < subImageSet[barItemIndex]._height)
mergeTagImageAndList(x - 1, y + i, minTag, minTagPointPos, barItemIndex);
}
//当前位置
TagImage(x, y, 0) = minTag;
PointPos cPoint(x, y + divideLinePointSet[barItemIndex] + 1);
pointPosListSet[minTag].push_back(cPoint);
}
//否则,作为新类
else {
addNewClass(x, y, barItemIndex);
}
}
}
}
}
void ImageSegmentation::addNewClass(int x, int y, int barItemIndex) {
tagAccumulate++;
//cout << "tagAccumulate " << tagAccumulate << endl;
TagImage(x, y, 0) = tagAccumulate;
classTagSet.push_back(tagAccumulate);
list pList;
PointPos cPoint(x, y + divideLinePointSet[barItemIndex] + 1);
pList.push_back(cPoint);
pointPosListSet.push_back(pList);
}
void ImageSegmentation::findMinTag(int x, int y, int &minTag, PointPos &minTagPointPos, int barItemIndex) {
if (subImageSet[barItemIndex](x, y - 1, 0) == 0) { //正上
if (TagImage(x, y - 1, 0) < minTag) {
minTag = TagImage(x, y - 1, 0);
minTagPointPos.x = x;
minTagPointPos.y = y - 1;
}
}
for (int i = -1; i <= 1; i++) { //左上、左中、左下
if (y + i < subImageSet[barItemIndex]._height) {
if (subImageSet[barItemIndex](x - 1, y + i, 0) == 0 && TagImage(x - 1, y + i, 0) < minTag) {
minTag = TagImage(x - 1, y + i, 0);
minTagPointPos.x = x - 1;
minTagPointPos.y = y + i;
}
}
}
}
void ImageSegmentation::mergeTagImageAndList(int x, int y, const int minTag, const PointPos minTagPointPos, int barItemIndex) {
//赋予最小标记,归并列表
if (subImageSet[barItemIndex](x, y, 0) == 0) {
int tagBefore = TagImage(x, y, 0);
if (tagBefore != minTag) { //不是最小的tag
//把所有同一类的tag替换为最小tag、把list接到最小tag的list
list::iterator it = pointPosListSet[tagBefore].begin();
for (; it != pointPosListSet[tagBefore].end(); it++) {
TagImage((*it).x, (*it).y - divideLinePointSet[barItemIndex] - 1, 0) = minTag;
}
pointPosListSet[minTag].splice(pointPosListSet[minTag].end(), pointPosListSet[tagBefore]);
}
}
}
3) 算法的实现主要参考两个链接:
https://segmentfault.com/a/1190000006120473
https://en.wikipedia.org/wiki/Connected-component_labeling
但是可以发现我这里对链接说的算法做一些改进。上面链接提到的是对图像一行行像素进行扫描,而这导致的结果是,上面也提到了:输出数字的顺序乱了,越高的数字排在越前输出。因此我根据这个算法的原理做出以下改进:
① 从一行行扫描改为一列列扫描
② 从取左前、左上、正上、右上4个点检测连通域,改为取正上、左上、左前、左下4个点检测连通域。
阶段结果:
3.6、对每张子图,存储单个数字以及一个图像名列表文本:
由于后面SVM预测的读入格式要求,需要将需要预测的图像的名字制造成一张列表文本:
void ImageSegmentation::saveSingleNumberImageAndImglist(int barItemIndex) {
for (int i = 0; i < pointPosListSet.size(); i++) {
if (pointPosListSet[i].size() != 0) {
//先找到数字的包围盒
int xMin, xMax, yMin, yMax;
getBoundingOfSingleNum(i, xMin, xMax, yMin, yMax);
int width = xMax - xMin;
int height = yMax - yMin;
//将单个数字填充到新图像:扩充到正方形
//int imgSize = (width > height ? width : height) + SingleNumberImgBoundary * 2;
//CImg singleNum = CImg(imgSize, imgSize, 1, 1, 0);
//list::iterator it = pointPosListSet[i].begin();
//for (; it != pointPosListSet[i].end(); it++) {
// int x = (*it).x;
// int y = (*it).y;
// int singleNumImgPosX, singleNumImgPosY;
// if (height > width) {
// singleNumImgPosX = (x - xMin) + (imgSize - width) / 2;
// singleNumImgPosY = (y - yMin) + SingleNumberImgBoundary;
// }
// else {
// singleNumImgPosX = (x - xMin) + SingleNumberImgBoundary;
// singleNumImgPosY = (y - yMin) + (imgSize - height) / 2;
// }
// singleNum(singleNumImgPosX, singleNumImgPosY, 0) = 255;
//}
//将单个数字填充到新图像:原长宽比
int imgSizeH = height + SingleNumberImgBoundary * 2;
int imgSizeW = width + SingleNumberImgBoundary * 2;
CImg singleNum = CImg(imgSizeW, imgSizeH, 1, 1, 0);
list::iterator it = pointPosListSet[i].begin();
for (; it != pointPosListSet[i].end(); it++) {
int x = (*it).x;
int y = (*it).y;
int singleNumImgPosX, singleNumImgPosY;
singleNumImgPosX = (x - xMin) + SingleNumberImgBoundary;
singleNumImgPosY = (y - yMin) + SingleNumberImgBoundary;
singleNum(singleNumImgPosX, singleNumImgPosY, 0) = 255;
}
//singleNum.display("single Number");
string postfix = ".bmp";
char shortImgName[200];
sprintf(shortImgName, "%d_%d%s\n", barItemIndex, classTagSet[i], postfix.c_str());
imglisttxt += string(shortImgName);
char addr[200];
sprintf(addr, "%s%d_%d%s", basePath.c_str(), barItemIndex, classTagSet[i], postfix.c_str());
singleNum.save(addr);
}
}
imglisttxt += "*\n";
//把tag集、每一类链表数据集清空
classTagSet.clear();
for (int i = 0; i < pointPosListSet.size(); i++) {
pointPosListSetForDisplay.push_back(pointPosListSet[i]);
pointPosListSet[i].clear();
}
pointPosListSet.clear();
}
(用*号将原图上一行行数字分隔)
好了!大功告成,后面的SVM预测只需要读入bmp以及txt就可以了~
剩余问题:
1、单独数字连接了起来,需要分开:
像下面的2 6 2连在一起了,需要再研究如何分开。