本文结合一个基于Python和OpenCV实现的答题卡识别程序,介绍其中涉及的灰度化、高斯模糊、Canny边缘检测、轮廓检测、轮廓近似、透视变换、阈值处理、掩膜、位运算以及像素统计等知识点。整个程序不需要使用深度学习模型,而是通过传统的计算机视觉方法完成基本的答题卡识别。
一、答题卡识别的整体思路
一张普通的答题卡图片,除了真正需要识别的选项之外,还可能包含背景、边框、文字以及其他干扰信息。如果直接统计整张图片中的像素,很难准确判断学生选择了哪个答案。
因此,需要按照一定的流程逐步处理图像。
整个程序的基本流程如下:
读取原始图片
↓
灰度化
↓
高斯模糊
↓
Canny边缘检测
↓
轮廓检测
↓
寻找答题卡四边形
↓
透视变换
↓
二值化
↓
检测选项轮廓
↓
筛选并排序选项
↓
掩膜提取选项区域
↓
统计非零像素
↓
确定学生答案
↓
与标准答案比较
↓
计算正确率
代码首先读取答题卡图片,然后通过灰度化和模糊处理减少图像中的干扰,再利用Canny算法提取边缘。之后通过轮廓检测找到答题卡的外轮廓,并利用四个角点进行透视变换,使答题卡变成比较规整的正视图。
完成透视变换之后,再对答题卡进行二值化处理,通过轮廓筛选找到每道题的选项区域。最后利用掩膜和非零像素统计判断哪个选项被填涂得最多,从而确定学生的答案。
二、图像预处理与答题卡区域检测
1. 灰度化和高斯模糊
程序读取图片后,首先将彩色图像转换成灰度图:
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
彩色图片通常包含B、G、R三个通道,而当前任务主要关注答题卡的边缘和轮廓,因此没有必要一直使用三个颜色通道。转换成灰度图后,图像只剩下一个通道,可以减少后续计算量。
接下来使用高斯模糊:
blurred = cv2.GaussianBlur(gray, (5, 5), 0)
高斯模糊的主要作用是降低图像噪声,使图像更加平滑。对于实际拍摄的答题卡来说,图片中可能存在一些细小的噪声,如果直接进行边缘检测,容易产生很多不需要的边缘,因此在Canny检测之前进行适当的模糊处理是比较常见的做法。
2. Canny边缘检测
经过高斯模糊后,使用Canny算法提取图像边缘:
edged = cv2.Canny(blurred, 75, 200)
Canny边缘检测可以寻找图像中像素变化比较明显的位置。答题卡的外框、选项圆圈等区域都具有比较明显的边缘,因此可以利用Canny结果作为后续轮廓检测的输入。
然后使用:
cnts = cv2.findContours(
edged.copy(),
cv2.RETR_EXTERNAL,
cv2.CHAIN_APPROX_SIMPLE
)[-2]
查找图像中的轮廓。
轮廓可以理解为图像中连续边缘形成的封闭区域。通过轮廓分析,就可以从复杂的图像中寻找具有特定形状的区域。代码还使用 cv2.drawContours() 将检测到的轮廓绘制到原图上,方便观察检测效果。
3. 利用轮廓近似寻找答题卡
得到轮廓以后,程序按照面积从大到小进行排序:
cnts = sorted(
cnts,
key=cv2.contourArea,
reverse=True
)
答题卡通常是图片中面积比较大的矩形区域,因此可以优先检查面积较大的轮廓。
接下来遍历轮廓:
for c in cnts:
peri = cv2.arcLength(c, True)
approx = cv2.approxPolyDP(
c,
0.02 * peri,
True
)
if len(approx) == 4:
docCnt = approx
break
这里使用 cv2.approxPolyDP() 对轮廓进行近似。如果近似后的轮廓只有4个顶点,就将其认为是答题卡区域。
这种方法利用了答题卡整体接近矩形这一特点。通过轮廓面积和顶点数量进行筛选,可以比较简单地定位答题卡的外框。
三、四点透视变换与答题区域校正
找到答题卡之后,并不能直接进行选项识别。
这是因为实际拍摄时,摄像头可能并不是正对着答题卡。例如答题卡本身是一个矩形,但由于拍摄角度的原因,在照片中可能变成一个倾斜的四边形。
如果直接在这种图像上识别选项,就会增加判断难度。
因此,程序使用四点透视变换将答题卡调整为规则的矩形。
首先使用 order_points() 对检测到的四个角点进行排序:
def order_points(pts):
rect = np.zeros((4, 2), dtype="float32")
s = pts.sum(axis=1)
rect[0] = pts[np.argmin(s)]
rect[2] = pts[np.argmax(s)]
diff = np.diff(pts, axis=1)
rect[1] = pts[np.argmin(diff)]
rect[3] = pts[np.argmax(diff)]
return rect
这里利用坐标的和与差来判断四个点的位置。
对于左上角和右下角:
s = pts.sum(axis=1)
坐标和最小的点作为左上角,坐标和最大的点作为右下角。
对于另外两个点,通过:
diff = np.diff(pts, axis=1)
确定右上角和左下角。
经过排序后,四个点就可以按照左上、右上、右下、左下的顺序保存。
接下来计算变换后的宽度和高度,并构造目标坐标:
dst = np.array([
[0, 0],
[maxWidth – 1, 0],
[maxWidth – 1, maxHeight – 1],
[0, maxHeight – 1]
], dtype="float32")
然后利用:
M = cv2.getPerspectiveTransform(rect, dst)
计算透视变换矩阵,最后通过:
warped = cv2.warpPerspective(
image,
M,
(maxWidth, maxHeight)
)
完成透视变换。
透视变换完成以后,原本倾斜的答题卡就被校正成一个比较规整的矩形。
这个知识点不仅可以用于答题卡识别,还经常用于身份证识别、文档扫描、试卷校正、票据处理等场景。
四、选项检测与自动判分
完成透视变换之后,程序重新将图像转换为灰度图:
warped = cv2.cvtColor(
warped_t,
cv2.COLOR_BGR2GRAY
)
然后进行二值化处理:
thresh = cv2.threshold(
warped,
0,
255,
cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU
)[1]
这里使用了OTSU阈值算法。
OTSU可以根据图像的灰度分布自动寻找一个比较合适的阈值,不需要手动指定具体的阈值大小。同时使用反向二值化,使填涂区域更容易被后面的像素统计检测出来。
接下来再次进行轮廓检测:
cnts = cv2.findContours(
thresh,
cv2.RETR_EXTERNAL,
cv2.CHAIN_APPROX_SIMPLE
)[-2]
此时检测到的轮廓主要来自答题卡上的选项区域。
为了排除其他无关轮廓,程序通过外接矩形的宽、高以及宽高比进行筛选:
(x, y, w, h) = cv2.boundingRect(c)
ar = w / float(h)
if w >= 20 and h >= 20 and 0.9 <= ar <= 1.1:
questionCnts.append(c)
这里要求宽度和高度都不能太小,同时宽高比需要接近1。因为答题卡选项通常是圆形或者接近正方形的区域,所以这种方法能够排除大量不符合要求的轮廓。
得到所有选项后,还需要对它们进行排序:
questionCnts = sort_contours(
questionCnts,
method="top-to-bottom"
)[0]
sort_contours()函数可以根据轮廓外接矩形的位置进行排序。程序先从上到下排列所有题目,然后每5个选项作为一道题,再对这一组选项从左到右排序。
确定每道题的5个选项后,就可以判断学生选择了哪个答案。
程序为每一个选项创建一个Mask:
mask = np.zeros(
thresh.shape,
dtype="uint8"
)
cv2.drawContours(
mask,
[c],
-1,
255,
-1
)
Mask的作用可以理解为一个“区域开关”。只让当前选项区域参与后续计算,其他区域全部忽略。
之后使用:
thresh_mask_and = cv2.bitwise_and(
thresh,
thresh,
mask=mask
)
进行位与操作,只保留当前选项内部的像素。
最后通过:
total = cv2.countNonZero(
thresh_mask_and
)
统计当前选项区域中非零像素的数量。
如果某个选项被学生填涂,那么这个区域中的非零像素通常会明显多于其他没有填涂的选项。因此程序只需要比较5个选项的 total 值,就可以找到填涂最多的选项:
if bubbled is None or total > bubbled[0]:
bubbled = (total, j)
这样就完成了学生答案的识别。
五、答案匹配与正确率计算
识别出学生答案以后,就需要和标准答案进行比较。
代码最开始定义了一个答案字典:
ANSWER_KEY = {
0: 1,
1: 4,
2: 0,
3: 3,
4: 1
}
这里的键代表题目编号,值代表正确选项的位置。
在完成每一道题的识别后,程序获取标准答案:
k = ANSWER_KEY[q]
然后将标准答案与学生选择的答案进行比较:
if k == bubbled[1]:
color = (0, 255, 0)
correct = correct + 1
如果答案正确,就增加正确题数量,并使用绿色进行标记;如果答案错误,则使用红色标记。
程序通过:
cv2.drawContours(
warped_new,
[cnts[k]],
-1,
color,
3
)
将对应选项绘制出来,使最终结果更加直观。
最后根据总题数计算正确率:
score = (correct / 5.0) * 100
再通过 cv2.putText() 将正确率写入图片:
cv2.putText(
warped_new,
'{:.2f}%'.format(score),
(10, 30),
cv2.FONT_HERSHEY_SIMPLEX,
0.9,
(0, 0, 255),
2
)
最终可以得到带有判题结果和正确率的答题卡图片,同时在控制台输出:
正确率:xx.xx%
整个程序到这里就完成了从原始答题卡图片到自动判分的完整过程。



