C++视觉开发 五.答题卡识别

news2024/11/15 2:07:26

 

目录

一.单道题目的识别

1.基本流程及原理

2.实现程序

二.整张答题卡原理

1.图像预处理

Canny 边缘检测

2.答题卡处理

cv::warpPerspective

cv::getPerspectiveTransform

3.筛选出所有选项

cv::boundingRect

4.将选项按题目分组

三.完整实现程序

1.实现代码

2.结果图


 答题卡识别主要步骤

(1)反二值化,选项处理为前景(白色),其它处理为背景(黑色)。

(2)每个选项提取出来,计算各选项白色像素点个数。

(3)筛选出白色像素点最多的选项作为考生答案。

(4)与标准答案比较,给出评阅结果。

基本实现原理图

一.单道题目的识别

1.基本流程及原理

实现步骤流程图

(1)标准答案及选项初始化

为了方便处理,将各个选项放入map的键值对中,不同选项对应不同索引。

// 标准答案及选项初始化
std::map<int, std::string> ANSWER_KEY = { {0, "A"}, {1, "B"}, {2, "C"}, {3, "D"} };
std::string ANSWER = "C";

(2) 读取原始图像

    // 读取原始图像
    cv::Mat img = cv::imread("xiaogang.jpg");
    if (img.empty()) {
        std::cout << "图像读取失败!" << std::endl;
        return -1;
    }
    cv::imshow("original", img);

(3)图像预处理

首先进行灰度化和高斯滤波去噪处理,然后进行阈值变换。阈值变换使用的是反二值化阈值处理,将图像内较暗的部分(如铅笔填涂的答案、选项标记等)处理为白色,将图像内相对较亮的部分(如白色等)处理为黑色。之所以这样处理是因为,通常用白色表示前景,前景是需要处理的对象;用黑色表示背景,背景是不需要额外处理的部分。

    // 图像预处理
    cv::Mat gray, gaussian_blur, thresh;
    cv::cvtColor(img, gray, cv::COLOR_BGR2GRAY);
    cv::GaussianBlur(gray, gaussian_blur, cv::Size(5, 5), 0);
    cv::threshold(gaussian_blur, thresh, 0, 255, cv::THRESH_BINARY_INV | cv::THRESH_OTSU);
    // cv::imshow("thresh", thresh);

(4)获取轮廓及排序

获取轮廓是图像处理的关键,借助轮廓能够确定每个选项的位置、选项是否被选中等。
需要注意的是,使用findcontours函数获取的轮廓的排列是没有规律的。因此需要将获取的各选项的轮廓按照从左到右出现的顺序排序,即map中的索引顺序。

    // 获取轮廓及排序
    std::vector<std::vector<cv::Point>> cnts;
    cv::findContours(thresh, cnts, cv::RETR_EXTERNAL, cv::CHAIN_APPROX_SIMPLE);
    std::vector<cv::Rect> boundingBoxes;
    for (const auto& c : cnts) {
        boundingBoxes.push_back(cv::boundingRect(c));
    }
    std::sort(boundingBoxes.begin(), boundingBoxes.end(), [](const cv::Rect& a, const 
               cv::Rect& b) {
        return a.x < b.x;
        });

(5)计算每个选项非零值(白色像素点)个数及序号

本步骤主要完成任务如下,
任务1:提取每一个选项。
任务2:计算每一个选项内的自色像素点个数。
对于任务1,使用按位与运算的掩模方式完成,示意图如图所示,根据“任意数值与自身进行按位与运算,结果仍旧是自身值”及掩模指定计算区域的特点:

如左图所示,将图像与自身进行按位与运算时,得到的仍旧是图像自身
如右图所示,在指定了掩模后,图像与自身相与所得的结果图像中与掩模对应部分保留原值;其余部分均为黑色。

 掩膜示意图

    // 构建列表,用来存储每个选项非零值(白色像素点)个数及序号
    std::vector<std::pair<int, int>> options;
    for (size_t j = 0; j < cnts.size(); ++j) {
        // 构造一个与原始图像大小一致的灰度图像,用来保存每一个选项用
        cv::Mat mask = cv::Mat::zeros(gray.size(), CV_8UC1);
        cv::drawContours(mask, cnts, j, cv::Scalar(255), -1);

        // 获取thresh中mask指定部分
        cv::Mat result;
        cv::bitwise_and(thresh, mask, result);
        cv::imshow("mask" + std::to_string(j), mask);
        cv::imshow("result" + std::to_string(j), result);

        // 计算每一个选项的非零值(白色像素点)
        int total = cv::countNonZero(result);
        options.push_back(std::make_pair(total, j));
    }

(6)识别考生作答选项

白色像素点最多的即为考生选项,如图,考生选项为B

选项示意图

根据轮廓内白色像素点的个数将轮廓降序排列,最前面的即为考生选项。

    // 识别考生的选项
    std::sort(options.begin(), options.end(), std::greater<>());
    int choice_num = options[0].second;
    std::string choice = ANSWER_KEY[choice_num];
    std::cout << "该生的选项:" << choice << std::endl;

(7)输出结果

根据选项正确与否,用不同颜色标注考生选项,正确标注绿色轮廓,错误标注红色轮廓。

    // 根据选项正确与否,用不同颜色标注考生选项
    cv::Scalar color;
    std::string msg;
    if (choice == ANSWER) {
        color = cv::Scalar(0, 255, 0);  // 回答正确,用绿色表示
        msg = "回答正确";
    }
    else {
        color = cv::Scalar(0, 0, 255);  // 回答错误,用红色表示
        msg = "回答错误";
    }
    cv::drawContours(img, cnts, choice_num, color, 2);
    cv::imshow("result", img);
    std::cout << msg << std::endl;

2.实现程序

完整代码如下:

#include <opencv2/opencv.hpp>
#include <iostream>
#include <vector>
#include <algorithm>

// 标准答案及选项初始化
std::map<int, std::string> ANSWER_KEY = { {0, "A"}, {1, "B"}, {2, "C"}, {3, "D"} };
std::string ANSWER = "C";

int main() {
    // 读取原始图像
    cv::Mat img = cv::imread("xiaogang.jpg");
    if (img.empty()) {
        std::cout << "图像读取失败!" << std::endl;
        return -1;
    }
    cv::imshow("original", img);

    // 图像预处理
    cv::Mat gray, gaussian_blur, thresh;
    cv::cvtColor(img, gray, cv::COLOR_BGR2GRAY);
    cv::GaussianBlur(gray, gaussian_blur, cv::Size(5, 5), 0);
    cv::threshold(gaussian_blur, thresh, 0, 255, cv::THRESH_BINARY_INV | cv::THRESH_OTSU);
    // cv::imshow("thresh", thresh);

    // 获取轮廓及排序
    std::vector<std::vector<cv::Point>> cnts;
    cv::findContours(thresh, cnts, cv::RETR_EXTERNAL, cv::CHAIN_APPROX_SIMPLE);
    std::vector<cv::Rect> boundingBoxes;
    for (const auto& c : cnts) {
        boundingBoxes.push_back(cv::boundingRect(c));
    }
    std::sort(boundingBoxes.begin(), boundingBoxes.end(), [](const cv::Rect& a, const cv::Rect& b) {
        return a.x < b.x;
        });

    // 构建列表,用来存储每个选项非零值(白色像素点)个数及序号
    std::vector<std::pair<int, int>> options;
    for (size_t j = 0; j < cnts.size(); ++j) {
        // 构造一个与原始图像大小一致的灰度图像,用来保存每一个选项用
        cv::Mat mask = cv::Mat::zeros(gray.size(), CV_8UC1);
        cv::drawContours(mask, cnts, j, cv::Scalar(255), -1);

        // 获取thresh中mask指定部分
        cv::Mat result;
        cv::bitwise_and(thresh, mask, result);
        cv::imshow("mask" + std::to_string(j), mask);
        cv::imshow("result" + std::to_string(j), result);

        // 计算每一个选项的非零值(白色像素点)
        int total = cv::countNonZero(result);
        options.push_back(std::make_pair(total, j));
    }

    // 识别考生的选项
    std::sort(options.begin(), options.end(), std::greater<>());
    int choice_num = options[0].second;
    std::string choice = ANSWER_KEY[choice_num];
    std::cout << "该生的选项:" << choice << std::endl;

    // 根据选项正确与否,用不同颜色标注考生选项
    cv::Scalar color;
    std::string msg;
    if (choice == ANSWER) {
        color = cv::Scalar(0, 255, 0);  // 回答正确,用绿色表示
        msg = "回答正确";
    }
    else {
        color = cv::Scalar(0, 0, 255);  // 回答错误,用红色表示
        msg = "回答错误";
    }
    cv::drawContours(img, cnts, choice_num, color, 2);
    cv::imshow("result", img);
    std::cout << msg << std::endl;

    cv::waitKey(0);
    cv::destroyAllWindows();
    return 0;
}

二.整张答题卡原理

整张答题卡识别的核心就是单道题目的识别。

识别流程如下:

1.图像预处理

图像预处理主要完成读取图像、色彩空间转换、高斯滤波、Canny边缘检测、获取轮廓等。
色彩空间转换:将图像从RGB色彩空间转换到灰度空间,以便后续处理。
高斯滤波:主要用于对图像进行去噪处理。为了得到更好的去噪效果,可以根据需要加入形态学如腐蚀、膨胀等操作。
Canny边缘检测:是为了获取Canny边缘,以便更好地完成后续获取图像轮的操作。

获取轮廓:是指将图像内的所有轮廓提取出来。函数findcontours可以根据参数查找图像内特定的轮廓。例如,通过参数cv2.RETR EXTERNAL可以实现仅查找所有外轮廓。

 其它的操作之前都用到过,下面介绍一下canny边缘检测

Canny 边缘检测

是一种多步骤的图像处理算法,被认为是最优的边缘检测算法之一。Canny 边缘检测的目标是找出图像中显著的边缘,并去除可能由噪声引起的虚假边缘。

在OpenCV中,cv::Canny函数直接实现了Canny边缘检测算法的所有步骤,包括使用Sobel算子计算梯度、非极大值抑制和双阈值检测。

函数语法:

void cv::Canny(
    InputArray image,        // 输入图像
    OutputArray edges,       // 输出边缘图像
    double threshold1,       // 低阈值
    double threshold2,       // 高阈值
    int apertureSize = 3,    // Sobel 算子的孔径大小(默认为 3)
    bool L2gradient = false  // 是否使用更精确的 L2 范数计算梯度幅度(默认为 false)
);
参数含义
image输入图像,通常为灰度图像
edges输出边缘图像,与输入图像大小相同。
threshold1低阈值,用于边缘连接
threshold2高阈值,用于检测强边缘。
apertureSizeSobel 算子的孔径大小,默认为 3
L2gradient

使用 L2 范数计算梯度幅度。

默认为 false,如果设为 true,则使用更精确但更耗时的计算方式。 

 预处理代码:

#include <opencv2/opencv.hpp>
#include <iostream>

int main() {
    // 读取图像
    cv::Mat img = cv::imread("b.jpg");
    if (img.empty()) {
        std::cerr << "图像读取失败!" << std::endl;
        return -1;
    }
    cv::imshow("original", img);

    // 转换为灰度图像
    cv::Mat gray;
    cv::cvtColor(img, gray, cv::COLOR_BGR2GRAY);
    cv::imshow("gray", gray);

    // 高斯滤波
    cv::Mat gaussian;
    cv::GaussianBlur(gray, gaussian, cv::Size(5, 5), 0);
    cv::imshow("gaussian", gaussian);

    // Canny边缘检测
    cv::Mat edged;
    cv::Canny(gaussian, edged, 50, 200);
    cv::imshow("edged", edged);

    // 查找轮廓
    std::vector<std::vector<cv::Point>> cts;
    std::vector<cv::Vec4i> hierarchy;
    cv::findContours(edged.clone(), cts, hierarchy, cv::RETR_EXTERNAL, cv::CHAIN_APPROX_SIMPLE);

    // 在图像上绘制轮廓
    cv::drawContours(img, cts, -1, cv::Scalar(0, 0, 255), 3);
    cv::imshow("img", img);

    cv::waitKey(0);
    cv::destroyAllWindows();

    return 0;
}

2.答题卡处理

将答题卡铺满整个页面(倾斜校正、删除无效边缘),将选项处理为白色,背景处理为黑色。
答题卡的处理,需要解决如下几个核心问题。
问题1:如何从众多轮廓中找到答题卡的轮廓?
问题2:如何对答题卡进行倾斜校正、裁剪掉扫描的边缘?
问题3:如何实现前景、背景的有效处理?
问题4:如何找到答题卡内所有选项?

问题1: 如何从众多轮廓中找到答题卡的轮廓?

在将答题卡铺满整个页面前,最重要的步骤是判定哪个轮廓是答题卡的轮廓。也就是说,需要先找到答题卡,再对其处理。

方法1:通常情况下,将函数findContours的method参数值设定为cv2.CHAIN APPROX SIMPLE当它识别到矩形时,就会使用4个顶点来保存其轮廓信息因此,可以通过判定轮廓是否用4个顶点表示,来判定轮廓是不是矩形。这个方法简单易行,但是在扫描答题卡时,可能会发生失真,使得原本是矩形的答题卡变成梯形。此时,简单地通过轮廓的顶点个数判断对象是否是答题卡就无效了。不过,在采用逼近多边形拟合轮廓时,可以使用4个顶点拟合梯形。因此,通过逼近多边形的顶点个数可以判定一个轮廓是否是梯形:若一个轮廓的逼近多边形是4个顶点,则该轮廓是梯形;否则,该轮廓不是梯形。

方法2:除此之外,还有一个方法是在找到的众多轮廓中,面积最大的轮廓可能是答题卡。因此,可以将面积最大的轮廓对应的对象判定为答题卡。

问题2: 如何对答题卡进行倾斜校正、裁剪掉扫描的边缘?

通常情况下,通过扫描等方式得到的答题卡可能存在较大的黑边及较大程度的倾斜,需要对其进行校正。该操作通常通过透视变换实现。透视变换可以将矩形映射为任意四边形,在0pencv中可通过函数warpPerspective实现。

cv::warpPerspective

函数语法:

void cv::warpPerspective(
    InputArray src,           // 输入图像
    OutputArray dst,          // 输出图像
    InputArray M,             // 3x3 透视变换矩阵
    Size dsize,               // 输出图像的大小
                )

由此可知,函数warpPerspective通过变换矩阵将原始图像src转换为目标图像dst。因此,在通过透视变换对图像进行倾斜校正时,需要构造一个变换矩阵。0penCv提供的函数getPerspectiveTransform能够构造从原始图像到目标图像(矩阵)之间的变换矩阵M。

cv::getPerspectiveTransform

函数语法

Mat cv::getPerspectiveTransform(
    InputArray src, // 输入图像中的四个点
    InputArray dst  // 输出图像中的四个点
);

通过轮廓查找,确定轮廓的逼近多边形,找到答题卡(待校正的不规则四边形)的四个顶点。由于并不知道这四个顶点分别是左上、右上、左下、右下四个顶点中的哪个顶点,因此需要在函数内先确定好这四个顶点分别对应左上、右上、左下、右下四个顶点中的哪个顶点。然后将这四个顶点和目标图像的四个顶点按照一致的排列方式传递给函数getPerspectiveTransform获取变换矩阵。最后根据变换矩阵,使用函数warpPerspective完成倾斜校正。

示例应用:倾斜校正、裁边处理

#include <opencv2/opencv.hpp>
#include <iostream>
#include <vector>
#include <cmath>

// 自定义透视函数
cv::Mat myWarpPerspective(const cv::Mat& image, const std::vector<cv::Point2f>& pts) {
    // step1:参数pts是要做倾斜校正的轮廓的逼近多边形(本题中的答题纸)的四个顶点,
    // 首先,确定四个顶点分别对应(左上、右上、右下、左下)的哪一个位置
    // step1.1:根据x轴值排序对4个点进行排序
    std::vector<cv::Point2f> xSorted = pts;
    std::sort(xSorted.begin(), xSorted.end(), [](const cv::Point2f& a, const cv::Point2f& b) { return a.x < b.x; });

    //step1.2:四个点划分为:左侧2个、右侧2个
    std::vector<cv::Point2f> left(xSorted.begin(), xSorted.begin() + 2);
    std::vector<cv::Point2f> right(xSorted.begin() + 2, xSorted.end());

    // step1.3:在左半边寻找左上角、左下角
    // 根据y轴的值排序
    std::sort(left.begin(), left.end(), [](const cv::Point2f& a, const cv::Point2f& b) { return a.y < b.y; });
    // 排在前面的是左上角(tl:top-left)、排在后面的是左下角(bl:bottom-left)
    cv::Point2f tl = left[0];
    cv::Point2f bl = left[1];

    // step1.4:根据右侧两个点与左上角点的距离判断右侧两个点的位置
    // 计算右侧两个点距离左上角点的距离
    std::vector<float> D;
    for (const auto& point : right) {
        D.push_back(cv::norm(tl - point));
    }

    // 右侧两个点,距离左上角远的点是右下角(br)的点,近的点是右上角的点(tr)
    cv::Point2f br = right[D[0] < D[1] ? 1 : 0];
    cv::Point2f tr = right[D[0] < D[1] ? 0 : 1];

    // step1.5:确定pts的四点分别属于(左上、左下、右上、右下)的哪一个
    std::vector<cv::Point2f> src = { tl, tr, br, bl };

    // step2:根据pts的四个顶点,计算出校正后图像的宽度和高度
    float widthA = std::sqrt(std::pow(br.x - bl.x, 2) + std::pow(br.y - bl.y, 2));
    float widthB = std::sqrt(std::pow(tr.x - tl.x, 2) + std::pow(tr.y - tl.y, 2));
    int maxWidth = static_cast<int>(std::max(widthA, widthB));

    float heightA = std::sqrt(std::pow(tr.x - br.x, 2) + std::pow(tr.y - br.y, 2));
    float heightB = std::sqrt(std::pow(tl.x - bl.x, 2) + std::pow(tl.y - bl.y, 2));
    int maxHeight = static_cast<int>(std::max(heightA, heightB));

    // 根据宽度、高度,构造新图像dst对应的的四个顶点
    std::vector<cv::Point2f> dst = {
        cv::Point2f(0, 0),
        cv::Point2f(maxWidth - 1, 0),
        cv::Point2f(maxWidth - 1, maxHeight - 1),
        cv::Point2f(0, maxHeight - 1)
    };

    // 构造从src到dst的透视变换矩阵
    cv::Mat M = cv::getPerspectiveTransform(src, dst);

    // 完成从src到dst的透视变换
    cv::Mat warped;
    cv::warpPerspective(image, warped, M, cv::Size(maxWidth, maxHeight));

    // 返回透视变换的结果
    return warped;
}

int main() {
    // 读取输入图像
    cv::Mat img = cv::imread("b.jpg");
    if (img.empty()) {
        std::cerr << "Could not open or find the image" << std::endl;
        return -1;
    }

    // 转换为灰度图像
    cv::Mat gray;
    cv::cvtColor(img, gray, cv::COLOR_BGR2GRAY);

    // 高斯模糊
    cv::Mat gaussian_blur;
    cv::GaussianBlur(gray, gaussian_blur, cv::Size(5, 5), 0);

    // 边缘检测
    cv::Mat edged;
    cv::Canny(gaussian_blur, edged, 50, 200);

    // 查找轮廓
    std::vector<std::vector<cv::Point>> contours;
    std::vector<cv::Vec4i> hierarchy;
    cv::findContours(edged, contours, hierarchy, cv::RETR_EXTERNAL, cv::CHAIN_APPROX_SIMPLE);

    cv::Mat paper;
    // 遍历每一个轮廓,找到答题纸的轮廓
    for (const auto& c : contours) {
        double peri = 0.01 * cv::arcLength(c, true);
        std::vector<cv::Point> approx;
        cv::approxPolyDP(c, approx, peri, true);

        // 四个顶点的轮廓是矩形(或者是由于扫描等原因由矩形变成的梯形)
        if (approx.size() == 4) {
            std::vector<cv::Point2f> points;
            for (const auto& point : approx) {
                points.push_back(cv::Point2f(point.x, point.y));
            }
            // 将外轮廓进行倾斜校正,将其构成一个矩形
            paper = myWarpPerspective(img, points);
            break;
        }
    }

    // 显示结果
    if (!paper.empty()) {
        cv::imshow("Paper", paper);
        cv::waitKey(0);
    }

    cv::destroyAllWindows();
    return 0;
}

问题3:如何实现前景、背景的有效处理

为了取得更好的识别效果,将图像内色彩较暗的部分(如A、B、C、D选项,填涂的答案等)处理为白色(作为前景),将颜色较亮的部分(答题卡上没有任何文字标记的部分、普通背景等)处理为黑色(作为背景)。采用反二值化阈值处理可以实现上述功能。反二值化阈值处理将图像中大于阈值的像素点处理为黑色小于阈值的像素点处理为白色。将函数threshold的参数设置为“Cv2.THRESH BINARY INV|cV2.THRESH OTSU”,可以获取图像的反二值化阈值处理结果。 

问题4:如何找到答题卡内所有选项

利用函数findcontours可以找到图像内的所有轮廓,因此可利用该函数找到答题卡内的所有选项。需要注意的是,上述处理不仅会找到答题卡内的所有选项轮廓,还会找到大量其他轮廓,如文字描述信息的轮廓、噪声轮廓等。因此后续需要进行噪声处理和进一步筛选。

3.筛选出所有选项

需要将各选项轮廓筛选出来,具体的筛选原则如下:
(1)轮廓要足够大,不能太小,具体量化为长度大于25像素、宽度大于25像素。
(2)轮廓要接近于圆形,不能太扁,具体量化为纵横比介于[0.6,1.3]。
将所有轮廓依次按照上述条件进行筛选,满足上述条件的轮判定为选项;否则,判定为噪声(说明文字等其他信息的轮廓)

筛选轮廓:遍历所有轮廓,使用 cv::boundingRect 函数获取轮廓的矩形包围框,计算纵横比并筛选符合条件的轮廓。

cv::boundingRect

功能:计算包围某个轮廓的最小矩形的函数。

函数语法:

Rect cv::boundingRect(InputArray points);

points:输入的点集或轮廓,可以是一个二维点的数组或 std::vector<cv::Point>

返回类型为cv::Rect。

应用示例:找到答题卡内所有选项轮廓

#include <opencv2/opencv.hpp>
#include <iostream>
#include <vector>

int main() {
    // 读取输入图像
    cv::Mat thresh = cv::imread("thresh.bmp", cv::IMREAD_UNCHANGED);
    if (thresh.empty()) {
        std::cerr << "Could not open or find the image" << std::endl;
        return -1;
    }

    cv::imshow("thresh_original", thresh);

    // 查找所有的轮廓
    std::vector<std::vector<cv::Point>> cnts;
    std::vector<cv::Vec4i> hierarchy;
    cv::findContours(thresh.clone(), cnts, hierarchy, cv::RETR_EXTERNAL, cv::CHAIN_APPROX_SIMPLE);
    std::cout << "共找到各种轮廓 " << cnts.size() << " 个" << std::endl;

    // 筛选出选项的轮廓
    std::vector<std::vector<cv::Point>> options;
    for (const auto& ci : cnts) {
        // 获取轮廓的矩形包围框
        cv::Rect boundingBox = cv::boundingRect(ci);
        int x = boundingBox.x;
        int y = boundingBox.y;
        int w = boundingBox.width;
        int h = boundingBox.height;

        // ar纵横比
        float ar = w / static_cast<float>(h);

        // 满足长度、宽度大于25像素,纵横比在[0.6, 1.3]之间,加入到options中
        if (w >= 25 && h >= 25 && ar >= 0.6 && ar <= 1.3) {
            options.push_back(ci);
        }
    }

    // 需要注意,此时得到了很多选项的轮廓,但是他们在options是无规则存放的
    std::cout << "共找到选项 " << options.size() << " 个" << std::endl;

    // 将所有找到的选项轮廓绘制出来
    cv::Scalar color = cv::Scalar(0, 0, 255);  // 红色

    // 为了以彩色显示,将原始图像转换为彩色空间
    cv::cvtColor(thresh, thresh, cv::COLOR_GRAY2BGR);

    // 绘制每个选项的轮廓
    cv::drawContours(thresh, options, -1, color, 5);

    // 显示结果
    cv::imshow("thresh_result", thresh);
    cv::waitKey();
    cv::destroyAllWindows();

    return 0;
}

 结果如图:

4.将选项按题目分组

在默认情况下,所有轮廓是无序排列的,因此无法直接使用序号将其划分到不同的题目上。若将所有选项轮廓按照从上到下的顺序排列,则可以获得如图所示的排序规律。由于第1道题目的四个选项一定在第2道题目的四个选项的上方,所以第1道题目的四个选项的序号一定是{0、1、2、3}这四个值,但是具体哪个选项对应哪个值不确定。同理,第2道题目的四个选项一定在第3道题目的上方,所以第2道题目的四个选项的序号一定是{4、5、6、7} 这四个值,以此类推:

排序结果示意图

示例代码:确定选项大致序号,每道题选项序号再下一道题前面。

#include <opencv2/opencv.hpp>
#include <iostream>
#include <vector>
#include <algorithm>

int main() {
    // 读取输入图像
    cv::Mat thresh = cv::imread("thresh.bmp", cv::IMREAD_UNCHANGED);
    if (thresh.empty()) {
        std::cerr << "Could not open or find the image" << std::endl;
        return -1;
    }

    // 查找所有的轮廓
    std::vector<std::vector<cv::Point>> cnts;
    std::vector<cv::Vec4i> hierarchy;
    cv::findContours(thresh.clone(), cnts, hierarchy, cv::RETR_EXTERNAL, cv::CHAIN_APPROX_SIMPLE);
    std::cout << "共找到各种轮廓 " << cnts.size() << " 个" << std::endl;

    // 将thresh转换为彩色图像,用于显示无序的轮廓编号
    cv::cvtColor(thresh, thresh, cv::COLOR_GRAY2BGR);
    cv::Mat result = thresh.clone(); // 用于显示排序后的轮廓编号

    // 筛选出选项的轮廓
    std::vector<std::vector<cv::Point>> options;
    cv::Scalar color = cv::Scalar(0, 0, 255); // 红色
    int font = cv::FONT_HERSHEY_SIMPLEX;

    for (size_t i = 0; i < cnts.size(); i++) {
        // 获取轮廓的矩形包围框
        cv::Rect boundingBox = cv::boundingRect(cnts[i]);
        int x = boundingBox.x;
        int y = boundingBox.y;
        int w = boundingBox.width;
        int h = boundingBox.height;

        // 计算纵横比
        float ar = w / static_cast<float>(h);

        // 满足长度、宽度大于25像素,纵横比在[0.6, 1.3]之间,加入到options中
        if (w >= 25 && h >= 25 && ar >= 0.6 && ar <= 1.3) {
            options.push_back(cnts[i]);
            // 绘制序号
            cv::putText(thresh, std::to_string(i), cv::Point(x-1, y-5), font, 0.5, color, 2);
        }
    }

    // 显示无序的选项编号
    cv::imshow("thresh", thresh);

    // 将轮廓按照从上到下的顺序排序
    std::vector<cv::Rect> boundingBoxes;
    for (const auto& opt : options) {
        boundingBoxes.push_back(cv::boundingRect(opt));
    }

    std::vector<std::pair<std::vector<cv::Point>, cv::Rect>> sortedOptions;
    for (size_t i = 0; i < options.size(); ++i) {
        sortedOptions.push_back(std::make_pair(options[i], boundingBoxes[i]));
    }

    std::sort(sortedOptions.begin(), sortedOptions.end(),
              [](const std::pair<std::vector<cv::Point>, cv::Rect>& a,
                 const std::pair<std::vector<cv::Point>, cv::Rect>& b) {
                  return a.second.y < b.second.y;
              });

    // 提取排序后的轮廓
    options.clear();
    for (const auto& item : sortedOptions) {
        options.push_back(item.first);
    }

    // 按照序号,显示排序后的轮廓
    for (size_t i = 0; i < options.size(); i++) {
        cv::Rect boundingBox = cv::boundingRect(options[i]);
        int x = boundingBox.x;
        int y = boundingBox.y;
        cv::putText(result, std::to_string(i), cv::Point(x-1, y-5), font, 0.5, color, 2);
    }

    // 显示排序后的结果
    cv::imshow("result", result);
    cv::waitKey();
    cv::destroyAllWindows();

    return 0;
}

结果如图:

在此基础上,还需要将每道题目的4个选项按照从左到右的顺序排列,在具体实现中,根据各选项的坐标值,实现各选项按从左到右顺序排列。 

示例代码:

    // 将每一题目的四个选项筛选出来并显示
    for (size_t tn = 0; tn < options.size(); tn += 4) {
        // 将轮廓按照坐标实现自左向右顺次存放
        std::vector<cv::Rect> boundingBoxes;
        for (size_t i = tn; i < tn + 4 && i < options.size(); ++i) {
            boundingBoxes.push_back(cv::boundingRect(options[i]));
        }

        std::vector<std::pair<std::vector<cv::Point>, cv::Rect>> sortedCnts;
        for (size_t i = 0; i < boundingBoxes.size(); ++i) {
            sortedCnts.push_back(std::make_pair(options[tn + i], boundingBoxes[i]));
        }

        std::sort(sortedCnts.begin(), sortedCnts.end(),
                  [](const std::pair<std::vector<cv::Point>, cv::Rect>& a,
                     const std::pair<std::vector<cv::Point>, cv::Rect>& b) {
                      return a.second.x < b.second.x;
                  });

        // 构造图像image用来显示每道题目的四个选项
        cv::Mat image = cv::Mat::zeros(thresh.size(), CV_8UC3);

        // 针对每个选项单独处理
        for (size_t n = 0; n < sortedCnts.size(); ++n) {
            const auto& ni = sortedCnts[n].first;
            cv::Rect boundingBox = sortedCnts[n].second;
            int x = boundingBox.x;
            int y = boundingBox.y;
            int w = boundingBox.width;
            int h = boundingBox.height;

            cv::drawContours(image, std::vector<std::vector<cv::Point>>{ni}, -1, cv::Scalar(255, 255, 255), -1);
            cv::putText(image, std::to_string(n), cv::Point(x-1, y-5), font, 1, cv::Scalar(0, 0, 255), 2);
        }

        // 显示每个题目的四个选项及对应的序号
        cv::imshow("result" + std::to_string(tn / 4), image);
    }

 接下来按照 (一) 中单道题目的识别逻辑进行。

三.完整实现程序

1.实现代码

#include <opencv2/opencv.hpp>
#include <iostream>
#include <vector>
#include <algorithm>
#include <cmath>
#include <map>

// 自定义函数,实现透视变换(倾斜校正)
cv::Mat myWarpPerspective(const cv::Mat& image, const std::vector<cv::Point2f>& pts) {
    // 根据x轴值排序对4个点进行排序
    std::vector<cv::Point2f> xSorted = pts;
    std::sort(xSorted.begin(), xSorted.end(), [](const cv::Point2f& a, const cv::Point2f& b) { return a.x < b.x; });

    // 四个点划分为:左侧2个、右侧2个
    std::vector<cv::Point2f> left(xSorted.begin(), xSorted.begin() + 2);
    std::vector<cv::Point2f> right(xSorted.begin() + 2, xSorted.end());

    // 在左半边寻找左上角、左下角,根据y轴的值排序
    std::sort(left.begin(), left.end(), [](const cv::Point2f& a, const cv::Point2f& b) { return a.y < b.y; });
    cv::Point2f tl = left[0];
    cv::Point2f bl = left[1];

    // 根据右侧两个点与左上角点的距离判断右侧两个点的位置
    std::vector<float> D;
    for (const auto& point : right) {
        D.push_back(cv::norm(tl - point));
    }

    cv::Point2f br = right[D[0] < D[1] ? 1 : 0];
    cv::Point2f tr = right[D[0] < D[1] ? 0 : 1];

    // 确定pts的四点分别属于(左上、左下、右上、右下)的哪一个
    std::vector<cv::Point2f> src = { tl, tr, br, bl };

    // 根据pts的四个顶点,计算出校正后图像的宽度和高度
    float widthA = std::sqrt(std::pow(br.x - bl.x, 2) + std::pow(br.y - bl.y, 2));
    float widthB = std::sqrt(std::pow(tr.x - tl.x, 2) + std::pow(tr.y - tl.y, 2));
    int maxWidth = static_cast<int>(std::max(widthA, widthB));

    float heightA = std::sqrt(std::pow(tr.x - br.x, 2) + std::pow(tr.y - br.y, 2));
    float heightB = std::sqrt(std::pow(tl.x - bl.x, 2) + std::pow(tl.y - bl.y, 2));
    int maxHeight = static_cast<int>(std::max(heightA, heightB));

    // 根据宽度、高度,构造新图像dst对应的四个顶点
    std::vector<cv::Point2f> dst = {
        cv::Point2f(0, 0),
        cv::Point2f(maxWidth - 1, 0),
        cv::Point2f(maxWidth - 1, maxHeight - 1),
        cv::Point2f(0, maxHeight - 1)
    };

    // 构造从src到dst的透视变换矩阵
    cv::Mat M = cv::getPerspectiveTransform(src, dst);

    // 完成从src到dst的透视变换
    cv::Mat warped;
    cv::warpPerspective(image, warped, M, cv::Size(maxWidth, maxHeight));

    // 返回透视变换的结果
    return warped;
}

// 标准答案
std::map<int, int> ANSWER = { {0, 1}, {1, 2}, {2, 0}, {3, 2}, {4, 3} };
// 答案用到的字典
std::map<int, std::string> answerDICT = { {0, "A"}, {1, "B"}, {2, "C"}, {3, "D"} };

int main() {
    // 读取原始图像(考卷)
    cv::Mat img = cv::imread("b.jpg");
    if (img.empty()) {
        std::cerr << "Could not open or find the image" << std::endl;
        return -1;
    }

    // 图像预处理:色彩空间变换
    cv::Mat gray;
    cv::cvtColor(img, gray, cv::COLOR_BGR2GRAY);

    // 图像预处理:高斯滤波
    cv::Mat gaussian_blur;
    cv::GaussianBlur(gray, gaussian_blur, cv::Size(5, 5), 0);

    // 图像预处理:边缘检测
    cv::Mat edged;
    cv::Canny(gaussian_blur, edged, 50, 200);

    // 查找轮廓
    std::vector<std::vector<cv::Point>> cts;
    std::vector<cv::Vec4i> hierarchy;
    cv::findContours(edged.clone(), cts, hierarchy, cv::RETR_EXTERNAL, cv::CHAIN_APPROX_SIMPLE);

    // 轮廓排序
    std::sort(cts.begin(), cts.end(), [](const std::vector<cv::Point>& a, const std::vector<cv::Point>& b) {
        return cv::contourArea(a) > cv::contourArea(b);
        });
    std::cout << "寻找轮廓的个数:" << cts.size() << std::endl;

    int rightSum = 0;

    // 遍历每一个轮廓,找到答题纸的轮廓,将答题纸处理进行倾斜校正
    for (const auto& c : cts) {
        double peri = 0.01 * cv::arcLength(c, true);
        std::vector<cv::Point> approx;
        cv::approxPolyDP(c, approx, peri, true);
        std::cout << "顶点个数:" << approx.size() << std::endl;

        // 四个顶点的轮廓是矩形(或者是由于扫描等原因由矩形变成的梯形)
        if (approx.size() == 4) {
            std::vector<cv::Point2f> pts;
            for (const auto& p : approx) {
                pts.push_back(cv::Point2f(p.x, p.y));
            }

            // 将外轮廓进行倾斜校正,将其构成一个矩形
            cv::Mat paper = myWarpPerspective(img, pts);
            cv::Mat paperGray = myWarpPerspective(gray, pts);

            // 反二值化阈值处理,选项处理为白色,答题卡整体背景处理黑色
            cv::Mat thresh;
            cv::threshold(paperGray, thresh, 0, 255, cv::THRESH_BINARY_INV | cv::THRESH_OTSU);

            // 在答题纸内寻找所有轮廓
            std::vector<std::vector<cv::Point>> cnts;
            cv::findContours(thresh.clone(), cnts, hierarchy, cv::RETR_EXTERNAL, cv::CHAIN_APPROX_SIMPLE);

            // 用options来保存每一个选项(选中,未选中都放进去)
            std::vector<std::vector<cv::Point>> options;

            // 遍历每一个轮廓,将选项放入到options中
            for (const auto& ci : cnts) {
                // 获取轮廓的矩形包围框
                cv::Rect boundingBox = cv::boundingRect(ci);
                int x = boundingBox.x;
                int y = boundingBox.y;
                int w = boundingBox.width;
                int h = boundingBox.height;

                // 计算纵横比
                float ar = w / static_cast<float>(h);

                // 满足条件,加入到options中
                if (w >= 25 && h >= 25 && ar >= 0.6 && ar <= 1.3) {
                    options.push_back(ci);
                }
            }

            // 将轮廓自上向下存放
            std::vector<cv::Rect> boundingBoxes;
            for (const auto& opt : options) {
                boundingBoxes.push_back(cv::boundingRect(opt));
            }

            std::vector<std::pair<std::vector<cv::Point>, cv::Rect>> sortedOptions;
            for (size_t i = 0; i < options.size(); ++i) {
                sortedOptions.push_back(std::make_pair(options[i], boundingBoxes[i]));
            }

            std::sort(sortedOptions.begin(), sortedOptions.end(),
                [](const std::pair<std::vector<cv::Point>, cv::Rect>& a,
                    const std::pair<std::vector<cv::Point>, cv::Rect>& b) {
                        return a.second.y < b.second.y;
                });

            // 提取排序后的轮廓
            options.clear();
            for (const auto& item : sortedOptions) {
                options.push_back(item.first);
            }

            // 处理每一道题的4个选项的轮廓
            for (size_t tn = 0; tn < options.size(); tn += 4) {
                // 将轮廓按照坐标实现自左向右顺次存放
                std::vector<cv::Rect> boundingBoxes;
                for (size_t i = tn; i < tn + 4 && i < options.size(); ++i) {
                    boundingBoxes.push_back(cv::boundingRect(options[i]));
                }

                std::vector<std::pair<std::vector<cv::Point>, cv::Rect>> sortedCnts;
                for (size_t i = 0; i < boundingBoxes.size(); ++i) {
                    sortedCnts.push_back(std::make_pair(options[tn + i], boundingBoxes[i]));
                }

                std::sort(sortedCnts.begin(), sortedCnts.end(),
                    [](const std::pair<std::vector<cv::Point>, cv::Rect>& a,
                        const std::pair<std::vector<cv::Point>, cv::Rect>& b) {
                            return a.second.x < b.second.x;
                    });

                // 构建列表ioptions,用来存储当前题目的每个选项(非零值个数,序号)
                std::vector<std::pair<int, int>> ioptions;

                // 提取出4个轮廓的每一个c,及序号ci
                for (size_t ci = 0; ci < sortedCnts.size(); ++ci) {
                    const auto& c = sortedCnts[ci].first;

                    // 构造一个核答题纸同尺寸的mask,灰度图像,黑色(值均为0)
                    cv::Mat mask = cv::Mat::zeros(paperGray.size(), CV_8UC1);

                    // 在mask内,绘制当前遍历到的选项轮廓
                    cv::drawContours(mask, std::vector<std::vector<cv::Point>>{c}, -1, 255, -1);

                    // 使用按位与运算的mask模式,提取出当前遍历到的选项
                    cv::Mat masked;
                    cv::bitwise_and(thresh, mask, masked);

                    // 计算当前遍历到选项内非零值个数
                    int total = cv::countNonZero(masked);

                    // 将选项非零值个数、选项序号放入列表ioptions内
                    ioptions.push_back(std::make_pair(total, ci));
                }

                // 将每道题的4个选项按照非零值个数降序排序
                std::sort(ioptions.begin(), ioptions.end(), [](const std::pair<int, int>& a, const std::pair<int, int>& b) {
                    return a.first > b.first;
                    });

                // 获取包含最多白色像素点的选项索引(序号)
                int choiceNum = ioptions[0].second;

                // 根据索引确定选项值:ABCD
                std::string choice = answerDICT[choiceNum];

                // 设定标注的颜色类型,绿对红错
                cv::Scalar color = (ANSWER[tn / 4] == choiceNum) ? cv::Scalar(0, 255, 0) : cv::Scalar(0, 0, 255);
                if (color == cv::Scalar(0, 255, 0)) {
                    rightSum++;
                }

                cv::drawContours(paper, std::vector<std::vector<cv::Point>>{sortedCnts[choiceNum].first}, -1, color, 2);
            }

            std::string s1 = "total: " + std::to_string(ANSWER.size());
            std::string s2 = "right: " + std::to_string(rightSum);
            std::string s3 = "score: " + std::to_string(static_cast<double>(rightSum) / ANSWER.size() * 100);
            int font = cv::FONT_HERSHEY_SIMPLEX;
            cv::putText(paper, s1 + "  " + s2 + "  " + s3, cv::Point(10, 30), font, 0.5, cv::Scalar(0, 0, 255), 2);
            cv::imshow("score", paper);

            // 找到第一个具有4个顶点轮廓,就是答题纸,直接break跳出循环
            break;
        }
    }

    cv::waitKey(0);
    cv::destroyAllWindows();

    return 0;
}

2.结果图

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1911421.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

支付宝沙箱对接(GO语言)

支付宝沙箱对接 1.1 官网1.2 秘钥生成&#xff08;系统默认&#xff09;1.3 秘钥生成&#xff08;软件生成&#xff09;1.4 golan 安装 SDK1.5 GoLand 代码1.6 前端代码 1.1 官网 沙箱官网: https://open.alipay.com/develop/sandbox/app 秘钥用具下载&#xff1a; https://ope…

MSPM0G3507——超声波模块移植代码

超声波没有做单独的代码文件 直接自己创建.c.h文件&#xff0c;将这些复制粘贴即可&#xff0c;然后进行SYSCFG配置按照这些配置即可&#xff0c;有啥问题直接评论区提出&#xff0c;如果看不懂的话评论区说一下&#xff0c;再出讲解 超声波.c文件 #include "ti_msp_dl…

线程并发库复习

1.进行和线程 什么是进程&#xff1a;进程是内存分配的基本单位&#xff0c;它是程序执行时的一个实例&#xff0c;会被放到进程就绪队列&#xff0c;等进程调度器选择它&#xff0c;给它时间片&#xff0c;它才会运行。在java中启动进程&#xff0c;main&#xff0c;test&…

【无标题】维度模型:

维度模型 基本概念维度模型中设计表主要分为2大类事实表维度表 基本概念 事实 &#xff1a;行为所产生的事情&#xff08;数据&#xff09; 维度&#xff1a;分析数据的角度&#xff08;状态&#xff09; 维度模型中设计表主要分为2大类 事实表&#xff1a;用于保存行为所产生…

实战 | YOLOv8使用TensorRT加速推理教程(步骤 + 代码)

导 读 本文主要介绍如何使用TensorRT加速YOLOv8模型推理的详细步骤与演示。 YOLOv8推理加速的方法有哪些? YOLOv8模型推理加速可以通过多种技术和方法实现,下面是一些主要的策略: 1. 模型结构优化 网络剪枝:移除模型中不重要的神经元或连接,减少模型复杂度。 模型精…

【通信协议】八、CDL(Caterpillar Data Link)协议解析

1、协议简介 CDL(Caterpillar Data Link)是caterpillar的通信协议,该品牌发动机ECM与各控制单元进行通信时,采用基于RS-485的物理层规范进行开发的CDL协议进行通信; 2、物理层 信号传输方式:差分信号(通过两条线的电压差识别逻辑0或逻辑1) 通信方式:半双工通信(只允…

iPhone短信被拉黑了怎么恢复?4步快速移除黑名单

在日常使用iPhone的过程中&#xff0c;可能会因为误操作或其他原因将某些联系人拉入黑名单&#xff0c;导致无法接收他们发送的短信。那么&#xff0c;iPhone短信被拉黑了怎么恢复&#xff1f; 其实&#xff0c;只需要简单的4步操作&#xff0c;就能快速将联系人移出黑名单&am…

Windows 安装 PyCharm

PyCharm下载 PyCharm官网&#xff1a;http://www.jetbrains.com/pycharm/download/ Professional 专业的&#xff0c;Community 社区&#xff0c;这里我们点击Community下的 DOWNLOAD下载。 PyCharm 安装使用 PyCharm是一种Python IDE&#xff0c;带有一整套可以帮助用户在使…

猎人维修大师免狗版

技术文档摘要 标题&#xff1a; 多功能维修工具集合概述 摘要&#xff1a; 本文档提供了一组多功能维修工具的概述&#xff0c;这些工具旨在为专业技术人员提供便利&#xff0c;以执行设备维修和软件解锁等任务。文档列出了各个工具的主要功能和应用场景。 关键词&#xff1…

【最新版】手把手Claude 3.5 Sonnet 详细步骤注册方法!

目录 01 Claude 3.5 Sonnet 是什么 02 Claude 3.5 Sonnet 注册方法 Step1&#xff1a;注册 Wildcard Step2&#xff1a;注册Claude3.5 Step3&#xff1a;接收并输入验证 03 使用Claude 3.5 Sonnet 04 升级Claude 3 Opus 05 结语 01 Claude 3.5 Sonnet 是什么 蛰伏了三…

鸿蒙开发:Universal Keystore Kit(密钥管理服务)【签名/验签介绍及算法规格】

签名/验签介绍及算法规格 为实现数据完整性保护和防抵赖&#xff0c;可使用生成/导入的密钥&#xff0c;对数据进行签名验签操作。 支持的算法 以下为密钥签名/验签支持的规格说明。 面向OpenHarmony的厂商适配密钥管理服务规格分为必选规格和可选规格。必选规格为所有厂商…

苍穹外卖--启用和禁用员工

实现 package com.sky.controller.admin;import com.sky.constant.JwtClaimsConstant; import com.sky.dto.EmployeeDTO; import com.sky.dto.EmployeeLoginDTO; import com.sky.dto.EmployeePageQueryDTO; import com.sky.entity.Employee; import com.sky.properties.JwtPro…

赛力斯25亿收购华为“问界”商标,估值102亿!

近日赛力斯发布公告&#xff0c;拟收购华为持有的商标及知识产权&#xff0c;华为其下属企业持有的九百一十九项商标权&#xff0c;其中七百四十项商标权已获授权&#xff0c;剩余商标权正在申请中&#xff0c;尚未获得商标注册证书&#xff0c;持有的商标及知识产权无形资产评…

Skywork-MoE,1460亿MoE模型,采用MoE Upcycling技术

Skywork-MoE&#xff0c;1460亿MoE模型&#xff0c;采用MoE Upcycling技术 原创 每日发现最新LLM 机器之心SOTA模型 2024年06月04日 18:27 北京 &#x1f3c6; 基座模型 ①项目名称&#xff1a;Skywork-MoE ★Skywork-MoE是一款千亿模型&#xff0c;具有1460亿参数、16个专家…

Java-Redis-Clickhouse-Jenkins-MybatisPlus-Zookeeper-vscode-Docker-jdbc-xxljob

文章目录 Clickhouse基础实操windows docker desktop 下载clickhousespringboot项目配置clickhouse Redis谈下你对Redis的了解&#xff1f;Redis一般都有哪些使用的场景&#xff1f;Redis有哪些常见的功能&#xff1f;Redis支持的数据类型有哪些&#xff1f;Redis为什么这么快…

初识CPlusPlus

前言 也是好久没写博客了&#xff0c;那些天也没闲着&#xff0c;去练题去了。实际上练题也可以写练题的博客&#xff0c;但是觉得太简单了些&#xff0c;于是就没有继续写下去。如今又回来写博客&#xff0c;是因为有整理了新的知识C。内容不算多&#xff0c;大多数都是书本上…

评估测试用例有效性 5个方面

评估测试用例的有效性是确保软件测试活动能够达到预期目标的关键步骤&#xff0c;有助于测试团队优化测试计划&#xff0c;提高测试效率&#xff0c;减少返工&#xff0c;节省成本。如果缺乏对测试用例的有效性评估&#xff0c;可能会导致测试用例无法覆盖关键功能点&#xff0…

微服务 | Springboot整合Seata+Nacos实现分布式事务

1、分布式事务概念说明 分布式系统会把一个应用系统拆分为可独立部署的多个服务&#xff0c;因此需要服务与服务之间远程协作才能完成事务操作&#xff0c;这种分布式系统环境下由不同的服务之间通过网络远程协作完成事务称之为分布式事务&#xff0c;例如&#xff1a;用户注册…

一站式短视频矩阵开发,高效托管!

短视频矩阵系统源码SaaS解决方案提供全面的开发服务&#xff0c;包括可视化视频编辑、矩阵式内容分发托管以及集成的多功能开发支持。 短视频矩阵&#xff1a;引爆您的数字营销革命 短视频矩阵系统是一套多功能集成解决方案&#xff0c;专为提升在短视频平台上的内容创作、管理…

[C++] 由C语言过渡到C++的敲门砖

命名空间 在C/C中&#xff0c;变量、函数和后⾯要学到的类都是⼤量存在的&#xff0c;这些变量、函数和类的名称将都存在于全局作⽤域中&#xff0c;可能会导致很多冲突。使⽤命名空间的⽬的是对标识符的名称进⾏本地化&#xff0c;以避免命名冲突或名字污染 。 在同一个工程中…