使用 C++ 和 OpenCV 构建智能答题卡识别系统 📝
本文将引导你如何使用 C++ 和强大的计算机视觉库 OpenCV,从零开始创建一个可以自动批改选择题答题卡的程序。我们将涵盖从图像预处理、轮廓定位到最终答案判定的完整流程。
核心技术与原理 🧠
光学标记识别 (OMR) 的核心思想是利用计算机视觉技术,在一张扫描的图像中定位并识别出被标记(如填涂)的区域。整个流程可以分解为以下几个关键步骤:
- 图像预处理: 将输入的彩色或灰度图像转换为二值图像,使其更容易被程序分析。
- 轮廓检测: 找到图像中的关键轮廓,首先是整个答题卡的轮廓,然后是每个选项的轮廓(通常是圆形或矩形)。
- 透视变换: 如果答题卡图像是倾斜的,我们需要将其校正为一个标准的"鸟瞰图",以确保后续处理的准确性。
- 选项定位与识别: 在校正后的图像上,定位每个选项(A, B, C, D)的位置。
- 答案判定: 通过计算每个选项区域内的非零像素(黑色像素)数量,来判断哪个选项被填涂。
- 自动评分: 将识别出的学生答案与标准答案进行比对,计算总分。
<center>一个简化的 OMR 处理流程图。</center>
步骤一:环境配置 🛠️
在开始之前,请确保你的开发环境中已经安装了 C++ 编译器 (如 G++) 和 OpenCV 库。
在 Ubuntu/Debian 上安装 OpenCV:
bash
sudo apt-get update
sudo apt-get install build-essential cmake libopencv-dev
步骤二:图像预处理与轮廓定位
我们的首要任务是找到答题卡在图像中的准确位置。
1. 加载与预处理
我们从加载图像开始,然后进行高斯模糊以减少噪声,再使用 Canny 算法进行边缘检测,最后通过膨胀和腐蚀操作来连接断开的边缘。
关键代码片段 (main.cpp):
cpp
#include <opencv2/opencv.hpp>
#include <iostream>
#include <vector>
using namespace cv;
using namespace std;
// 用于对轮廓按面积大小进行排序的辅助函数
bool compareContourAreas(const vector<Point>& contour1, const vector<Point>& contour2) {
return contourArea(contour1) > contourArea(contour2);
}
int main() {
string image_path = "answer_sheet.jpg"; // 你的答题卡图片路径
Mat image = imread(image_path);
if (image.empty()) {
cout << "Could not read the image: " << image_path << endl;
return 1;
}
Mat gray, blurred, edged;
cvtColor(image, gray, COLOR_BGR2GRAY);
GaussianBlur(gray, blurred, Size(5, 5), 0);
Canny(blurred, edged, 75, 200);
// 显示边缘检测结果 (可选)
// imshow("Edged", edged);
// waitKey(0);
// ... 后续代码
}
2. 寻找答题卡轮廓
在边缘图像上,我们可以寻找轮廓。答题卡通常是图像中最大的矩形轮廓。
cpp
// ... 接上文
vector<vector<Point>> contours;
findContours(edged.clone(), contours, RETR_EXTERNAL, CHAIN_APPROX_SIMPLE);
// 按面积对轮廓进行排序
sort(contours.begin(), contours.end(), compareContourAreas);
// 假设最大的轮廓就是我们的答题卡
vector<Point> paperContour;
for (const auto& c : contours) {
double peri = arcLength(c, true);
vector<Point> approx;
approxPolyDP(c, approx, 0.02 * peri, true);
// 如果轮廓有4个顶点,我们认为它就是答题卡
if (approx.size() == 4) {
paperContour = approx;
break;
}
}
步骤三:透视变换(图像校正)📐
找到四个顶点后,我们可以对图像进行透视变换,得到一个完美的矩形俯视图。
关键代码片段:
cpp
#include <algorithm> // for std::sort
// 对四个顶点进行排序:左上, 右上, 右下, 左下
Point2f sortPoints(const vector<Point>& pts) {
// ... 实现排序逻辑 ...
// 通常基于 x+y 和 x-y 的值来排序
// 返回一个包含四个有序点的 Point2f 向量
}
// ... 接上文
if (paperContour.empty()) {
cout << "Could not find paper contour." << endl;
return 1;
}
// 获取四个顶点并排序
Point2f src_pts[] = { /* 排序后的 paperContour 顶点 */ };
Point2f dst_pts[] = {{0.0f, 0.0f}, {500.0f, 0.0f}, {500.0f, 700.0f}, {0.0f, 700.0f}}; // 目标图像尺寸
Mat transformMatrix = getPerspectiveTransform(src_pts, dst_pts);
Mat warped;
warpPerspective(image, warped, transformMatrix, Size(500, 700));
// 显示校正后的图像 (可选)
// imshow("Warped", warped);
// waitKey(0);
步骤四:选项识别与评分 ✅
现在我们在校正后的、标准化的 warped
图像上工作。
1. 定位并分析选项气泡
首先,我们将校正后的图像再次进行二值化处理。
cpp
Mat warpedGray, thresh;
cvtColor(warped, warpedGray, COLOR_BGR2GRAY);
threshold(warpedGray, thresh, 0, 255, THRESH_BINARY_INV | THRESH_OTSU);
接下来,我们再次寻找轮廓,但这次是在二值化的 thresh
图像上。这些轮廓将代表所有的选项气泡。
cpp
vector<vector<Point>> bubbleContours;
findContours(thresh.clone(), bubbleContours, RETR_EXTERNAL, CHAIN_APPROX_SIMPLE);
vector<vector<Point>> questionBubbles;
// 遍历所有轮廓,筛选出符合条件的选项气泡(比如基于宽高比和面积)
for (const auto& c : bubbleContours) {
Rect r = boundingRect(c);
float ar = r.width / (float)r.height;
if (r.width >= 20 && r.height >= 20 && ar >= 0.9 && ar <= 1.1) {
questionBubbles.push_back(c);
}
}
2. 将气泡分组并判定答案
我们将所有识别出的气泡按其 y 坐标排序,然后按 x 坐标排序,这样就可以将它们与具体的问题和选项对应起来。假设每行有 5 个选项气泡(例如,一个题号加 A,B,C,D)。
cpp
// 按y坐标对气泡进行排序
sort(questionBubbles.begin(), questionBubbles.end(), [](const vector<Point>& a, const vector<Point>& b) {
return boundingRect(a).y < boundingRect(b).y;
});
map<int, int> correct_answers; // <题号, 正确答案索引(0-3)>
correct_answers[0] = 1; // 第1题答案是 B
correct_answers[1] = 3; // 第2题答案是 D
// ... 其他答案
int total_correct = 0;
// 每5个气泡为一组进行处理
for (size_t i = 0; i < questionBubbles.size(); i += 5) {
// 对当前行的5个气泡按x坐标排序
vector<vector<Point>> row(questionBubbles.begin() + i, questionBubbles.begin() + i + 5);
sort(row.begin(), row.end(), [](const vector<Point>& a, const vector<Point>& b) {
return boundingRect(a).x < boundingRect(b).x;
});
int bubbled_index = -1;
int max_filled = 0;
// 遍历当前行的每个选项 (A,B,C,D),跳过第一个(题号)
for (size_t j = 1; j < row.size(); ++j) {
Mat mask = Mat::zeros(thresh.size(), CV_8UC1);
drawContours(mask, row, j, Scalar(255), -1);
Mat masked_bubble;
bitwise_and(thresh, thresh, masked_bubble, mask);
int filled_pixels = countNonZero(masked_bubble);
if (filled_pixels > max_filled) {
max_filled = filled_pixels;
bubbled_index = j - 1; // 索引为 0-3
}
}
// 判定对错
int question_num = i / 5;
if (correct_answers[question_num] == bubbled_index) {
total_correct++;
}
}
cout << "Total Correct: " << total_correct << endl;
总结与提升 🚀
我们成功地使用 C++ 和 OpenCV 实现了一个基本的答题卡自动评分系统。这个项目完美地展示了计算机视觉在自动化任务中的强大能力。
可以进一步优化的方向:
- 鲁棒性: 提高对不同光照条件、纸张质量和填涂工具(铅笔、钢笔)的适应性。
- GUI 界面: 使用 Qt 或其他 GUI 框架为程序创建一个用户友好的界面,允许用户上传图片并查看结果。
- 多选题和判断题: 扩展逻辑以支持多种题型。
- 性能优化: 对计算密集型步骤进行优化,以更快地处理图像。
希望这个教程能帮助你开启计算机视觉的学习之旅!