使用 C++ 和 OpenCV 构建智能答题卡识别系统

使用 C++ 和 OpenCV 构建智能答题卡识别系统 📝

本文将引导你如何使用 C++ 和强大的计算机视觉库 OpenCV,从零开始创建一个可以自动批改选择题答题卡的程序。我们将涵盖从图像预处理、轮廓定位到最终答案判定的完整流程。


核心技术与原理 🧠

光学标记识别 (OMR) 的核心思想是利用计算机视觉技术,在一张扫描的图像中定位并识别出被标记(如填涂)的区域。整个流程可以分解为以下几个关键步骤:

  1. 图像预处理: 将输入的彩色或灰度图像转换为二值图像,使其更容易被程序分析。
  2. 轮廓检测: 找到图像中的关键轮廓,首先是整个答题卡的轮廓,然后是每个选项的轮廓(通常是圆形或矩形)。
  3. 透视变换: 如果答题卡图像是倾斜的,我们需要将其校正为一个标准的"鸟瞰图",以确保后续处理的准确性。
  4. 选项定位与识别: 在校正后的图像上,定位每个选项(A, B, C, D)的位置。
  5. 答案判定: 通过计算每个选项区域内的非零像素(黑色像素)数量,来判断哪个选项被填涂。
  6. 自动评分: 将识别出的学生答案与标准答案进行比对,计算总分。

<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 框架为程序创建一个用户友好的界面,允许用户上传图片并查看结果。
  • 多选题和判断题: 扩展逻辑以支持多种题型。
  • 性能优化: 对计算密集型步骤进行优化,以更快地处理图像。

希望这个教程能帮助你开启计算机视觉的学习之旅!

相关推荐
宋一平工作室6 分钟前
单片机队列功能模块的实战和应用
c语言·开发语言·stm32·单片机·嵌入式硬件
CodeWithMe7 分钟前
【软件开发】上位机 & 下位机概念
c++
豆豆(设计前端)19 分钟前
在 JavaScript 中,你可以使用 Date 对象来获取 当前日期 和 当前时间、当前年份。
开发语言·javascript·ecmascript
jndingxin22 分钟前
OpenCV CUDA模块图像变形------对图像进行 尺寸缩放(Resize)操作函数resize()
人工智能·opencv·计算机视觉
luofeiju25 分钟前
数字图像处理与OpenCV初探
c++·图像处理·python·opencv·计算机视觉
清醒的兰25 分钟前
OpenCV 多边形绘制与填充
图像处理·人工智能·opencv·计算机视觉
whoarethenext26 分钟前
使用 C/C++的OpenCV 将多张图片合成为视频
c语言·c++·opencv
weixin_4284984926 分钟前
Catch2 开源库介绍与使用指南
c++
luozhonghua200027 分钟前
opencv opencv_contrib vs2020 源码安装
人工智能·opencv·计算机视觉
吴声子夜歌28 分钟前
OpenCV——图像金字塔
人工智能·opencv·计算机视觉