答题卡图像识别

需求分析、市场分析和技术实现

一、需求分析

一、以接口的方式开发此需求:

1:接收图片

    以上传的方式把图片发送到接口。

2:识别图片

    接口接收到图片后,进行图像识别。

3:返回数据

    返回识别后的JSON格式数据。

二、答题卡图片识别的具体要求:

图片是通过手机、相机、扫描仪等设备拍照而来,其中手机、相机拍出的照片会出现像素低、图像不正、聚焦不清楚等问题;

1:图片只要是人眼能看清楚的即可完成识别; 
2
800万像素以上的手机拍的照片能进行识别; 
3
:聚焦不清楚时也可以进行识别;

4:不符合要求的图片可以不识别,一旦识别,正确率必须保证100%

三、其他要求:

1:此项目验收需要提供答题卡识别的所有源代码、接口说明文档。

2:接口需支持单张图片上传识别以及多张图片的上传识别。

3:接口使用的开发语言及开发工具不限。

四、需求分析:

    这是一个典型的“机器视觉”应用。其中,答题卡的样式可以是由自己来设置的,图片的获取方式提到了可以是“手机拍照、相机拍照”这种比较方便的方式;本例的一个特殊的要求是:你可以识别不出来,但是你不能识别错误,这是项目的特殊要求

五、需求分析:

普通的答题卡是这样的:

8691_80题AB型Z1Z2ABCD_B型

用于机器识别的答题卡是这样的,最明显的区别在于在边界处提供了用于标定的黑边。由于这里的答题卡是可以自己来设计的,就应该设计得最适合识别:

经过我修改的答题卡是这样的,主要是用圆点进行边界标定,因为在旋转和缩放的情况下,圆点都有更好的性能:

二、市场分析

答题卡已经出现好多年了,而且教育机构也是容易出现壁垒的领域。经过简单调查,制式的答题机应该是这种样子的,这种答题机采用的应该特殊的成像技术,比如红外之类的,否则也不需要做成这种样子:

其价格在数千元到万元左右,淘宝上也有人做出了机器识别的例子:

采用普通摄像头和特定的支架,销售情况不好。

但是,图像确是多种多样的。

     形式多样。值得关注的一点是,这些能够通过baidu直接搜索得到的答题卡在设计上和本文提供的答题开有两点比较大的不同,一个是在取消了比如圆点这样的标定点,二个是在横版面上采用了“点画”的方式进行标定

这样能够得到的结果还是使得答题卡更加的简洁,美观。

     对于这个市场,我认为在网络和即时聊天工具更加发达的今天,答题卡作为一种非常正式的考试方法,还是有其市场的(比如高考中考,短时间内还不会出现直接采用移动设备进行答卷);但是专门去做一套这样的设备,市场已经基本饱和,而且教育市场的壁垒应该很高,不是很容易就能够进入的。但是,对于在日常非正式考试中需要答题卡相关设备,而不希望担负一套昂贵的专业系统的人或单位来所,如果能够以一种比较低廉的价格,并且已一种比较方便操作的方式(比如直接利用手机,或普通相机)进行实现,应该是有一定的市场的。

三、技术实现

     本例的技术难度不是很大,非常关键的一点是由于卡片是可以由自己来设计的。而且图像的获取也比较容易被优化。这里以最前面的图片进行设计分析,其他的例子情况可以以此类推;并且公布核心代码。

1)仿照实际的情况,对原始图片进行相关处理。在实际拍摄的时候,可能会出现“缩放”、“透视变化”等影响最终实际结果的情况:

变小

透视变化

同时透视和缩放

2)编写获取锚点(就是圆点)的函数. FetchAnchorPoints函数的主要过程是将输入的图片划分为四个部分,并且分别找到其中的圆点。参数中mattmp是模板图片,也就是哪个小圆的图片。

 

//获得锚点
void FetchAnchorPoints(Mat src,Mat mattmp,Point &anchor01,Point &anchor02,Point &anchor03,Point &anchor04)
{
    Mat p_w_picpathmatch;
    Point minLoc; 
    Point maxLoc01,maxLoc02,maxLoc03,maxLoc04;
    //Point anchor01,anchor02,anchor03,anchor04;
    double minVal; 
    double maxVal2; 
    //Mat src = imread("C:/answercard/1.jpg",0);//读入黑白原始图像
    int srcRows = src.rows;
    int srcCols = src.cols;
    Mat src01 = src(Rect(0,0,srcCols/2,srcRows/2));
    Mat src02 = src(Rect(srcCols/2,0,srcCols/2,srcRows/2));
    Mat src03 = src(Rect(0,srcRows/2,srcCols/2,srcRows/2));
    Mat src04 = src(Rect(srcCols/2,srcRows/2,srcCols/2,srcRows/2));
    //imshow("src01",src01);imshow("src02",src02);imshow("src03",src03);imshow("src04",src04);
    matchTemplate( mattmp, src01, p_w_picpathmatch, 5 ); 
    normalize( p_w_picpathmatch, p_w_picpathmatch, 01, NORM_MINMAX, -1, Mat() );
    minMaxLoc( p_w_picpathmatch, &minVal, &maxVal2, &minLoc, &maxLoc01, Mat() ); 
    anchor01 = maxLoc01;
    //circle(src,maxLoc01,3,Scalar(0),3);
    matchTemplate( mattmp, src02, p_w_picpathmatch, 5 ); 
    normalize( p_w_picpathmatch, p_w_picpathmatch, 01, NORM_MINMAX, -1, Mat() );
    minMaxLoc( p_w_picpathmatch, &minVal, &maxVal2, &minLoc, &maxLoc02, Mat() ); 
    anchor02 = Point(maxLoc02.x+srcCols/2,maxLoc02.y);
    //circle(src,anchor02,3,Scalar(0),3);
    matchTemplate( mattmp, src03, p_w_picpathmatch, 5 ); 
    normalize( p_w_picpathmatch, p_w_picpathmatch, 01, NORM_MINMAX, -1, Mat() );
    minMaxLoc( p_w_picpathmatch, &minVal, &maxVal2, &minLoc, &maxLoc03, Mat() ); 
    anchor03 = Point(maxLoc03.x,maxLoc03.y+srcRows/2);
    //circle(src,anchor03,3,Scalar(0),3);
    matchTemplate( mattmp, src04, p_w_picpathmatch, 5 ); 
    normalize( p_w_picpathmatch, p_w_picpathmatch, 01, NORM_MINMAX, -1, Mat() );
    minMaxLoc( p_w_picpathmatch, &minVal, &maxVal2, &minLoc, &maxLoc04, Mat() ); 
    anchor04 = Point(maxLoc04.x+srcCols/2,maxLoc04.y+srcRows/2);
    //circle(src,anchor04,3,Scalar(0),3);
    
}

 

获得的结果

3)采用warpPerspective进行透视变换,如果对warpPerspective不是很了解可以查看我前面的blog

 

    Point anchor01,anchor02,anchor03,anchor04;    Point2f src_vertices[4];
    Point2f dst_vertices[4];
    //获得矫正结果图像的参数
    Mat matstandard = imread("C:/answercard/1.jpg",0);//读入黑白原始图像
    Mat mattmp = imread("C:/answercard/temp.jpg",0);
    FetchAnchorPoints(matstandard,mattmp,anchor01,anchor02,anchor03,anchor04);
    std::cout<<"anchor01"<<anchor01<<" "<<"anchor02"<<anchor02<<" "<<"anchor03"<<anchor03<<" "<<"anchor04"<<anchor04;
    dst_vertices[0= anchor01;
    dst_vertices[1= anchor02;
    dst_vertices[2= anchor03;
    dst_vertices[3= anchor04;
    //dst_vertices.push_back(anchor01);dst_vertices.push_back(anchor02);dst_vertices.push_back(anchor03);dst_vertices.push_back(anchor04);
    //获得需要矫正图像参数
    Mat matsrc = imread("C:/answercard/bigroatate.jpg",0);
    FetchAnchorPoints(matsrc,mattmp,anchor01,anchor02,anchor03,anchor04);
    cout<<"\n";
    std::cout<<"anchor01"<<anchor01<<" "<<"anchor02"<<anchor02<<" "<<"anchor03"<<anchor03<<" "<<"anchor04"<<anchor04;
    src_vertices[0= anchor01;
    src_vertices[1= anchor02;
    src_vertices[2= anchor03;
    src_vertices[3= anchor04;
    //src_vertices.push_back(anchor01);src_vertices.push_back(anchor02);src_vertices.push_back(anchor03);src_vertices.push_back(anchor04);
    //透视变化
    Mat warpMatrix = getPerspectiveTransform(src_vertices, dst_vertices);
    cv::Mat rotated;
    warpPerspective(matsrc, rotated, warpMatrix, rotated.size(), INTER_LINEAR, BORDER_CONSTANT);
    imshow("rotated",rotated);
    imshow("matstandard",matstandard);这一步得到的校正图像:4)对原始图像进行裁剪//对原始图像进行裁剪
    Mat roi01;Mat roi02;Mat roi03;Mat roi04;    anchor01 =dst_vertices[0] ;
    anchor02 =dst_vertices[1] ; 
    anchor03 =dst_vertices[2] ; 
    anchor04 =dst_vertices[3] ; 
    //TODO这个地方最终的时候需要改成rotated
    roi01 = matstandard(Rect(anchor01.x,anchor01.y+mattmp.rows,20,anchor03.y-anchor01.y-mattmp.rows));
    roi02 = matstandard(Rect(anchor01.x+mattmp.cols,anchor01.y,anchor02.x-anchor01.x-mattmp.cols,20));
    roi03 = matstandard(Rect(anchor02.x+8,anchor02.y+mattmp.rows,17,anchor04.y-anchor02.y-mattmp.rows));
    roi04 = matstandard(Rect(anchor03.x+mattmp.cols,anchor03.y+5,anchor04.x-anchor03.x-mattmp.cols,13));
    //roi02 = FetchMaxContour(roi02);
    //imshow("roi01",roi01);
    //imshow("roi02",roi02);
    //imshow("roi03",roi03);
    //imshow("roi04",roi04);这一步得到的结果 5)获得区域的投影。这里的操作其实就是获得图像的波峰,这样就能进行定位。//函数名称:FetchMaxContour
//函数作用: 对区域进行预处理,返回最大的连续区域
//参    数: src [in] 输入mat
//返    回:投影值
vector<int>  FetchMaxContour(Mat src)
{
    //读取图像
    Mat testmat = src.clone();
    Mat testclone = src.clone();
    Mat matcanny;
    //用于寻找轮廓
    Mat threshold_output;
    vector<vector<Point> > contours;
    vector<Vec4i> hierarchy;
    int imax = 0;int maxsize = 0;
    RotatedRect theMinRect;
    RotatedRect theMinEllipse;
    //imshow("原始图像",testmat);
    //大津法找到敏感区域
    threshold(testmat,testmat,0,255,cv::THRESH_OTSU); 
    //imshow("大津法",testmat);
    //为什么要转换,因为白色是有数据的区域,轮廓是围绕白色区域的
    threshold(testmat,testmat,0,255,THRESH_BINARY_INV);
    imshow("二值",testmat);
    //计算纵向投影
    vector<int> vcol;itmp = 0;
    for (int i=0;i<testmat.cols;i++)
    {
        for (int j=0;j<testmat.rows;j++)
        {
            if (testmat.at<uchar>(j,i))
            {
                itmp = itmp +1;
            }
        }
        vcol.push_back(itmp);
        itmp = 0;
    }
    对得到的结果进行处理,计算波峰
    //int isum = 0;//一共多少个波峰
    vector<int> vrise;
    for (int i=1;i<vcol.size();i++)
    {
        if (vcol[i-1]==0 && vcol[i]>0)
        {    
            vrise.push_back(i);
            //isum = isum+1;
        }
    }
    return vrise;
} 6)获得投影区域,并且标注出来    vector<int> vroi02 = FetchMaxContour(roi02);    vector<Mat> vmat02;
    for (int i=1;i<vroi02.size();i++)
    {
        Mat roi = rotated(Rect(mattmp.cols+anchor01.x+vroi02[i],roi02.rows+38,11,92));
        //imshow("roi",roi);
        vmat02.push_back(roi);
        circle(rotated,Point(mattmp.cols+anchor01.x+vroi02[i],roi02.rows+38),1,Scalar(0),1);
    }
    vector<int> vroi04 = FetchMaxContour(roi04);
    vector<Mat> vmat04;
    for (int i=0;i<vroi04.size();i++)
    {
        Mat roi = rotated(Rect(mattmp.cols+anchor03.x+vroi04[i],153,11,198));
        //imshow("roi",roi);
        //vmat02.push_back(roi);
        circle(rotated,Point(mattmp.rows+anchor03.x+vroi04[i],153),1,Scalar(0),1);
    }
    imshow("rotated",rotated); 找到的结果用圆点标注出来这里下面一排第一个圆点没找到,这是原始模板图像在设计的时候出现的问题,因为这里只是说明原理,我就没有修改。7)架设照相机,获取实际图片做到这一步,下面就是要获得实际的图片并进行识别了。我采用的方法是将答题卡用打印机打印出来,然后用相机拍摄下来,注意一下光照,效果如下:照片还是比较模糊的,识别后达到预期效果。注意模板识别之前首先需要把图片缩放一下,否则效果不会太好。Mat matsrc = imread("C:/answercard/r4.jpg",0);
    resize(matsrc,matsrc,Size(600,500));
    FetchAnchorPoints(matsrc,mattmp,anchor01,anchor02,anchor03,anchor04);
效果如此。采用2b铅笔进行填卡,效果如下进行阈值分析后,效果很差可以发现,采用2b铅笔,如果采用图像识别的方法的话,光照的影响还是非常大的。接着改用黑色铅笔(钢笔也可以)则特征明显8)对获取的结果进行计算。也就是图片到数据的一个量化的过程。具体来说,就是将这样的图像量化成为选择结果,思路也是非常直接的,就是对比最右侧的标尺值和实际获得的值。在编写具体代码的时候,可能还要加上一定的修正,并且要尽可能保证这个修正是鲁棒的。
    vector<int> vroi02 = FetchMaxContour(roi02);
    vector<Mat> vmat02;
    vector<int> vroi03 = FetchMaxContour(roi03,1);
    //减去偏移,这里的偏移量可以从roi03第一个值得出
    for (int i=0;i<vroi03.size();i++)
    {
        vroi03[i] = vroi03[i]-30;
    }
    int resulttmp = 9;
    cout<<"vroi02"<<endl;
    //这里i = 0的数据是无用数据
    for (int i=1;i<vroi02.size();i++)
    {
        Mat roi = rotated(Rect(mattmp.cols+anchor01.x+vroi02[i],roi02.rows+38,11,92));
        //vmat02.push_back(roi);
        vector<int> vtmp = FetchMaxContour(roi,1);
        vtmp[0= vtmp[0]+4;
        for (int k = 0;k<9;k++)
        {
            if (vtmp[0]>=vroi03[k] && vtmp[0]<vroi03[k+1])
            {
                resulttmp = k;
                break;
            }
         }
        cout<<i<<" is "<<resulttmp<<" | ";    
         cout<<endl;resulttmp = 9;
        if (IsDebug)
        {
            char* tmp = new char[100];
            sprintf(tmp,"C:/answercard/vmat02/%d.jpg",i);
            imwrite(tmp,roi);
            circle(rotated,Point(mattmp.cols+anchor01.x+vroi02[i],roi02.rows+38),1,Scalar(0),1);
        }
    } 结果完全正确:1 is 0 |2 is 1 |3 is 2 |4 is 4 |5 is 3 |6 is 4 |7 is 4 |8 is 5 |9 is 4 |10 is 4 |11 is 5 |12 is 6 |13 is 3 |

四、小结   答题卡这种东西很早之前就有了,我想在它第一次被提出的时候,绝对是创造性的、革命的东西,大大地提高了考试的生产率。但是之前的那种设备采用的原理比较精密和复杂,非常依赖设备,一定程度上限制了传播和发展。今天,随着机器视觉算法的不断发展、移动通信设备的不断发展,解决这种问题有了新的思路和新的市场空间。这也从另一个方向上说明了机器视觉技术的广阔前景。   本文从需求分析、市场分析和技术实现3个方面尝试对这个问题进行剖析。受制于资源和个人能力,很多地方解释的不是很清楚,最终开发出来的代码虽然具备了一定的解决问题的能力,但是毕竟不够鲁棒和高效。毕竟机器视觉的项目是由市场和实际需求驱动的,如果有好的想法和需求,欢迎交流。   感谢阅读,希望有所帮助。