多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

基于OpenCV的智能答题卡识别系统:从图像预处理到自动判分

基于OpenCV的智能答题卡识别系统:从图像预处理到自动判分 简介基于Python和OpenCV的智能答题卡识别系统完整项目包面向深度学习与图像识别方向的学习者、课程设计或毕业设计开发者目标是实现答题卡图像从预处理、区域定位到选项识别、答案判定与计分的全流程自动化。整套方案以OpenCV实现答题卡检测与选项区域分割以卷积神经网络等深度学习模型处理选项分类代码注释清晰层级分明适合作为实战练手或二次开发底座。压缩包共60个文件包含39张jpg图像样本、8个xml标注文件、5个py源程序、pyc编译文件以及xls成绩表和readme说明等总大小约38.86MB图像、代码、文档分类存放方便对照原始图片理解检测与分割逻辑。项目已有448人学习/下载覆盖多类型答题卡图片可直接运行体验。附带Excel处理脚本与详细说明能帮助快速完成标注梳理、答案核验和实验报告撰写对课设答辩或竞赛展示均具参考价值。1. 一张答题卡从扫描件变成成绩单这套识别链路到底怎么搭你打开一套刚扫描完的答题卡几十张 JPG 或 TIF每张纸上排着几十道选择题、两三排手写学号。过去靠人工对照答题卡模板批改一张卡两三分钟一个班四十分钟起步眼睛花掉不说还容易看串行。基于 Python OpenCV 智能答题卡识别系统做的事情就是把这条流程压成一两秒程序读图、拉正透视、切出每个选项格、判定涂写、比对标准答案、输出总分和错题清单最后把置信度不足的样本单独挑出来交给人工复查。这套方案不是要把整张卡丢给深度学习模型端到端“猜”而是让 OpenCV 处理结构明确的定位与涂写检测让深度学习只解决它擅长的手写识别和模糊判定。它适合正在做考试阅卷产品的开发者也适合拿图像识别算法练手、想完整跑通一个落地项目的学生和工程师。2. 识别管线的四个核心环节预处理、透视矫正、定位与涂写判定2.1 灰度、去噪与二值化为什么高斯模糊不能省答题卡识别的第一步永远是把彩色扫描件降成灰度图。颜色在选举题涂写检测里几乎不提供信息反而会让光照不均的问题更明显。读图之后立刻做灰度化接着做一次高斯模糊这两步几乎是固定动作。很多人觉得模糊会抹掉选项格的边缘实际上扫描件里的粉尘、纸张纹理、打印机噪点才是 Canny 边缘检测的大敌。高斯模糊把那些高频噪声先压掉Canny 拿到的边缘才会干净。二值化是整个环节里最容易被低估的一步。常见的做法是先看整张卡的灰度直方图如果答题卡背景是白色、涂写区域是深色直方图会呈现明显的双峰。这时候用cv2.threshold配一个固定阈值就能分开但前提是整张卡的亮度分布一致。扫描仪出的图一般还好手机拍摄的答题卡就会出现上半部分亮、下半部分暗的情况固定阈值当场翻车。我一般会在预处理阶段保留两个分支扫描仪图走固定阈值手机图走 Otsu 自适应阈值。后面第四章会详细写阈值参数怎么调。2.2 轮廓检测与透视变换把歪掉的答题卡拉正答题卡扫描件很少是完美水平的。高速扫描仪偶尔会走纸歪一个角度手机拍摄更是随便一放就是透视变形。识别系统里最关键的几何操作是找到答题卡的四条边然后把整个卡面映射到一个正矩形上。这个操作叫透视变换OpenCV 提供getPerspectiveTransform和warpPerspective两个函数配合使用。要找四条边得先拿到答题卡外轮廓。流程是Canny 边缘检测 →findContours找轮廓 → 按轮廓面积排序 → 取面积最大的轮廓 → 用approxPolyDP把轮廓拟合成四边形。这四步看起来简单实际坑很多。扫描件的黑边、桌面背景里的矩形物体都可能比答题卡面积更大所以纯按面积取最大的轮廓不一定可靠。我通常的做法是拿到面积前五的轮廓逐个做多边形逼近筛选出顶点数等于 4 且面积最大者。透视变换本身不复杂难点在四个角点的排序。findContours返回的四个点顺序是不确定的必须按“左上、右上、右下、左下”重新排列否则变换出来的图像会翻转或者变形。排序的常见做法是对四个点的坐标求和xy 最小的是左上xy 最大的是右下剩下两个按 x 坐标大小分出右上和左下。这套逻辑写起来十几行但每次都能用。2.3 格子定位先有模板还是先有坐标拉正之后下一个问题是怎么知道第一个选择题的 A 选项在哪个像素位置两种常见路线第一种是纯坐标法答题卡版式固定直接量出左上角的区域偏移量按行列数均分格子第二种是模板法在干净的空白答题卡上做一次标定记录每个格子的中心坐标然后存成模板文件识别新卡时只做透视变换再套坐标。对于标准印刷的通用答题卡坐标法足够了快而且不需要额外文件。但现实里答题卡版式五花八门行距、列距、题目间距都不一样。很多学校用的答题卡是第三方印刷的页眉页脚还有二维码和注意事项文字。坐标法一旦换成新模板整段切格代码都得跟着改。所以我建议做成模板驱动把每行的题目数、每个选项格子的宽高、整个答题区域的左上角坐标和右下角坐标写进一个 JSON 或者 Python 配置字典切格代码只读配置不写死数字。维护起来轻松很多换版式的时候只改配置不用动算法。2.4 深度学习在这条管线里到底负责什么这套系统的标题里同时出现 OpenCV 和深度学习很多人会以为要用卷积神经网络去“识别”整张答题卡。实际落地时把整张卡直接丢给神经网络是下策布局、打印字体、纸张底色各种差异都会被模型当作噪声学进去训练数据需求量大到不现实。强规则结构的问题就该用图像处理去解。深度学习在这条管线里的位置是补缺和兜底。三个典型场景一是手写学号的数字识别模板匹配在打印字体上表现不错但手写体的粗细、倾斜、连笔会让它性能崩盘小 CNN 在这个任务上明显更稳二是涂写很浅的选项像素密度在阈值边缘徘徊这时可以用一个分类模型做二次判断三是多选题多涂、橡皮没擦干净的脏迹判定。这些都属于“规则写不清楚、但人能一眼看出来”的问题正是深度学习适合的场景。选型时要把两种技术按成本分开看。OpenCV 的部分是确定性算法调参快、可解释、任意一台机器能跑深度学习部分需要数据、训练和验证引入一次的成本高但手写识别这种任务不用它又确实不行。一般建议是先把 OpenCV 的管线做到识别率 95% 以上再回头评估到底哪几个环节值得动用模型而不是上来就设计一个端到端网络。3. 从图像读取到透视矫正用 OpenCV 跑通第一段代码3.1 读取图像与预处理最低限度的三步操作先把最基础的一段代码写完整。下面的函数接收一张答题卡图片的路径输出灰度图、边缘图以及矫正后的图。注意这里我直接把后面要用到的透视变换也包含进来了方便一次性跑通看效果。import cv2 import numpy as np def load_and_preprocess(image_path): # 读取原始图像保持彩色以便后续可视化调试 img cv2.imread(image_path) if img is None: raise ValueError(f无法读取图像: {image_path}) # 第一步灰度化。答题卡处理基本用不到颜色通道 gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 第二步高斯模糊。(5, 5) 是卷积核尺寸0 让标准差自动计算 blurred cv2.GaussianBlur(gray, (5, 5), 0) # 第三步Canny 边缘检测。50 和 150 是低/高阈值 edges cv2.Canny(blurred, 50, 150) return img, gray, edges这段代码的每一步都有实际意义。灰度化把三通道降到单通道后续所有阈值计算都只需要处理一个通道速度直接提升三倍。高斯模糊的核大小就是去噪强度核太小噪声压不下去Canny 会识别出大量短线核太大会把答题卡边框本身的边缘也抹掉导致后面找不到矩形边界。Canny 的两个阈值比例一般维持 1:2 到 1:3低阈值控制边缘连接的敏感度高阈值控制强边缘的认定标准。扫描件比较干净时50/150 是好起点手机拍摄的图建议把低阈值提到 80 以上。3.2 轮廓查找与四边形逼近怎么准确抢到答题卡外框拿到边缘图之后要从中挑出答题卡最外层的矩形边框。下面这段代码做了三件事找轮廓、按面积排序、多边形逼近并筛选四边形。def find_sheet_quad(edges): # RETR_EXTERNAL 只取最外层轮廓避免答题卡内部图表的干扰 contours, _ cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if len(contours) 0: raise RuntimeError(没有找到任何轮廓) # 按面积从大到小排序取前 5 个逐个判断 contours sorted(contours, keycv2.contourArea, reverseTrue)[:5] for contour in contours: # epsilon 是逼近精度取轮廓周长的 2% epsilon 0.02 * cv2.arcLength(contour, True) approx cv2.approxPolyDP(contour, epsilon, True) # 真正的答题卡外边框应该是一个四边形 if len(approx) 4: return approx.reshape(4, 2) raise RuntimeError(前 5 个轮廓里没有四边形请检查边缘检测参数)RETR_EXTERNAL是这里的关键它只返回最外层的轮廓把答题卡内部的题目边框、二维码、选项格全部忽略掉。approxPolyDP的epsilon值表示逼近允许的最大误差取周长的 2% 是一个经验值太大容易把答题卡外边框拟合成三角形或五边形太小又会让边框上的微小锯齿都保留下来导致顶点数超过 4。如果这个函数频繁报错优先检查的是模糊核大小和 Canny 阈值的配合而不是改 epsilon。3.3 透视变换与角点排序参数选错整张图就歪了找到四个角点之后还有一个必经步骤给四个点排序。OpenCV 返回的顶点顺序不固定直接丢给透视变换函数会导致矫正结果错乱。下面的order_points按位置特征把点归位到左上、右上、右下、左下。def order_points(pts): pts pts.astype(float32) rect np.zeros((4, 2), dtypefloat32) # 左上角 xy 最小右下角 xy 最大 s pts.sum(axis1) rect[0] pts[np.argmin(s)] # 左上 rect[2] pts[np.argmax(s)] # 右下 # 剩下两个点x 较小的为左下x 较大的为右上 left pts[np.argmin(pts[:, 0])] right pts[np.argmax(pts[:, 0])] rect[1] right # 右上 rect[3] left # 左下 return rect def four_point_transform(image, pts): rect order_points(pts) tl, tr, br, bl rect # 计算目标矩形的宽高取上下/左右两条边的最大长度 width_top np.linalg.norm(tr - tl) width_bottom np.linalg.norm(br - bl) height_left np.linalg.norm(bl - tl) height_right np.linalg.norm(br - tr) max_width max(int(width_top), int(width_bottom)) max_height max(int(height_left), int(height_right)) dst np.array([ [0, 0], [max_width - 1, 0], [max_width - 1, max_height - 1], [0, max_height - 1] ], dtypefloat32) matrix cv2.getPerspectiveTransform(rect, dst) warped cv2.warpPerspective(image, matrix, (max_width, max_height)) return warped排序逻辑看着简单但np.argmin(pts[:, 0])这一行在处理严重倾斜的卡片时要小心当卡片旋转超过 45 度时“x 最小的点”可能不是左下而是左上排序就会错位。更稳的写法是用四个点两两之间的向量关系来判断但上面的版本已经覆盖绝大多数扫描场景。warpPerspective的第三个参数是输出尺寸我用最大边长来保证纸张内容不裁切。如果矫正后的图出现白边或者拉伸变形通常是宽高比例不对检查一下原始卡片的宽高比按比例设置目标尺寸即可。参数汇总如下表方便排查时对照参数推荐值作用失效表现GaussianBlur 核大小5×5去噪抑制纸张纹理太小边缘杂乱太大边框丢失Canny 低阈值50扫描件/ 80手机图控制弱边缘响应阈值低则轮廓过多高则边框断裂Canny 高阈值低阈值的 2~3 倍控制强边缘认定比例失调导致边缘不连续approxPolyDP epsilon轮廓周长的 2%控制多边形逼近精度过大顶点数不足过小顶点数超标目标矩形尺寸上下/左右最大边长保证透视矫正不裁切宽高比失真、内容拉伸4. 选项涂写检测与判分核心代码与数据集准备4.1 按模板切出答题格行列参数的设定透视矫正之后的答题卡是一张规整的矩形图。接下来要做的是按行列坐标把每个选择题的 A、B、C、D 选项格切出来。这里用配置驱动的方式最稳把行列数和距边框的留白都放到一个字典里。def split_choice_cells(warped_gray, config): config 示例: { rows: 30, # 题目数 cols: 4, # 每题的选项数 top_margin: 60, # 答题区距顶部的像素 bottom_margin: 40, # 答题区距底部的像素 left_margin: 50, # 答题区距左侧的像素 right_margin: 50 # 答题区距右侧的像素 } h, w warped_gray.shape[:2] rows config[rows] cols config[cols] top config[top_margin] bottom config[bottom_margin] left config[left_margin] right config[right_margin] cell_h (h - top - bottom) // rows cell_w (w - left - right) // cols cells [] for r in range(rows): for c in range(cols): x1 left c * cell_w y1 top r * cell_h x2 x1 cell_w y2 y1 cell_h cells.append((x1, y1, x2, y2)) return cells切格本身不需要什么算法但它决定了后面所有判定的准确性。cell_h和cell_w是整除出来的如果答题卡总高度减去上下留白后不能被题目数整除最后一行的格子会贴着边界涂写密度计算会偏高。遇到这种情况我的做法是让最后一行直接复用上一行的行高拿剩余像素做校正而不是改变所有格子的高度。切格之前最容易被忽略的一步是确认矫正后的图像是否水平。如果透视变换这一步做得不干净卡片还有 1~2 度的旋转那靠均分坐标切出来的格子越靠右越偏移最终整列选项的涂写判定全错。这类问题在扫描仪图里不常见但在手机拍摄图里几乎是必然出现。4.2 涂写密度计算与阈值判定2B 铅笔和圆珠笔的灰度差切出格子之后判断一道题填了 A 还是 C靠的是“涂写区域和非涂写区域的灰度差异”。下面的函数计算单个格子的涂写像素密度密度超过阈值就认定该选项被选中。def fill_ratio(gray, cell_box): x1, y1, x2, y2 cell_box roi gray[y1:y2, x1:x2] # 对格子内部单独做 Otsu 阈值自动适应该区域的亮度 _, binary cv2.threshold( roi, 0, 255, cv2.THRESH_BINARY_INV cv2.THRESH_OTSU ) # 涂写区域在 THRESH_BINARY_INV 下是白色像素值为 255 filled_pixels cv2.countNonZero(binary) total_pixels roi.shape[0] * roi.shape[1] return filled_pixels / total_pixels def detect_choice(filled_ratios, threshold0.45): filled_ratios: 长度为 4 的列表对应 A/B/C/D 的涂写密度 threshold: 低于该密度视为未填涂 返回值: 选中的选项索引如果所有选项密度都低于阈值返回 -1 max_ratio max(filled_ratios) if max_ratio threshold: return -1 return filled_ratios.index(max_ratio)这段代码里有个关键设计对每个格子独立做 Otsu 阈值而不是对整张卡统一阈值。原因前面提过扫描亮度不均匀时整卡阈值会让亮区的浅色涂写被判成空白、暗区的空白被判成涂写。逐格 Otsu 等于给每个格子一次“自我校准”的机会代价是计算量稍大但现在机器性能完全扛得住。阈值 0.45 是一个经验起点。2B 铅笔正常填涂的密度通常在 0.5~0.7 之间圆珠笔因为笔迹细、有油光密度会低到 0.3 左右。所以如果答题卡明确要求圆珠笔填涂阈值要降到 0.25~0.3如果允许铅笔和圆珠笔混用那这个阈值就比较难受。更靠谱的办法是先收集十几张真实答题卡把填涂格的密度分布画出来取两个峰之间的谷值作为阈值。这种阈值调参有点玄学但批量样本出来之后会非常准。4.3 学号手写识别合成数据集加上一个小 CNN学号是每张答题卡必须关联到人的关键信息。传统模板匹配对手写数字的容忍度太低同样的 2 写在不同人手里长宽比和倾斜角差异很大。这里用小 CNN 是性价比最高的方案。模型本身不需要复杂几层卷积加全连接足够。import torch import torch.nn as nn class StudentIDNet(nn.Module): def __init__(self): super().__init__() self.conv1 nn.Conv2d(1, 16, 3, padding1) self.conv2 nn.Conv2d(16, 32, 3, padding1) self.pool nn.MaxPool2d(2) self.fc1 nn.Linear(32 * 7 * 7, 64) self.fc2 nn.Linear(64, 10) def forward(self, x): x self.pool(torch.relu(self.conv1(x))) # 28x28 - 14x14 x self.pool(torch.relu(self.conv2(x))) # 14x14 - 7x7 x x.view(x.size(0), -1) x torch.relu(self.fc1(x)) return self.fc2(x)这个网络结构直接对标手写数字识别里的 LeNet 思路输入是 28×28 的灰度图。训练数据是这个任务真正的难点。真实答题卡手写样本很难大批量获取常见做法是用合成数据打底把数字用多种字体渲染到灰色背景上加上随机旋转、平移、缩放和噪声模拟扫描效果。想深挖的可以参考动手学深度学习里 LeNet 章节的数据增强思路。训练参数按经验给一组batch_size 32Adam 优化器学习率 1e-3训练 15 个 epoch。数据增强要刻意加得“狠”一点随机旋转 ±10 度、平移 ±2 像素、亮度抖动 0.8~1.2否则模型在合成数据上表现很好遇到真实手写就立刻暴露过拟合问题。训练完成后把每个数字归一化到 28×28输入模型取输出向量的最大概率索引作为识别结果。4.4 判分逻辑标准答案比对与错题输出判分逻辑不复杂但有一个容易踩的细节一道题可能填了多个选项或者一个选项都没填。判分函数必须把这两种情况分开处理。def grade_sheet(filled_ratios, answer_key, question_start0, options_per_question4, threshold0.45): filled_ratios: 所有格子的涂写密度顺序与 split_choice_cells 返回的格子一一对应 answer_key: {题目号: 正确选项索引} 返回: (得分, 正确题号列表, 错误题号列表, 未填涂题号列表) correct_ids [] wrong_ids [] blank_ids [] for qid, answer in answer_key.items(): start (qid - question_start) * options_per_question options_ratio filled_ratios[start:start options_per_question] picked detect_choice(options_ratio, threshold) if picked -1: blank_ids.append(qid) elif picked answer: correct_ids.append(qid) else: wrong_ids.append(qid) score len(correct_ids) return score, correct_ids, wrong_ids, blank_ids判分时建议把“未填涂”和“填错”分开返回。因为在实际阅卷场景里空白题可以直接判定为 0 分但填错的题可能需要复核是不是涂写太浅被漏判。多选填空题的逻辑和选择题类似只是选项数不同把options_per_question改成对应数值即可。判断题可以当作两个选项的选择题处理A 代表对、B 代表错。错题输出最好落到 CSV 文件带上学号和每个题目的识别结果。后续如果要做成绩分析或者人工复核直接读 CSV 就行不用重新跑一遍图像处理。5. 智能答题卡识别系统避坑五个高频翻车现场与排查顺序5.1 环境报错ModuleNotFoundError: No module named cv2现象import cv2直接抛ModuleNotFoundError: No module named cv2或者安装之后cv2.findContours运行时崩溃。原因基本是包装错了环境。常见情况是机器上装了多个 Python终端里默认的python指向 3.8但pip install opencv-python装到了 3.9 的 site-packages 里。还有一个经典坑numpy 版本和 OpenCV 版本不兼容cv2.findContours处理 numpy 数组时直接段错误。解决统一用python -m pip install opencv-python numpy安装确保装进当前python对应的环境。如果已经装乱了pip show opencv-python查看安装位置python -c import sys; print(sys.executable)确认当前解释器路径。Windows 下有的人会花半天去编译 MinGW 版的 OpenCV 3.4.1其实官方预编译包已经很稳定没必要自己折腾构建链。Linux 上常见做法是apt install libopencv-dev或者直接走 pip两条路都能通。5.2 答案区域外轮廓找不准拿到的是桌面阴影而不是答题卡边框现象approxPolyDP返回的四边形面积巨大透视矫正之后图像内容完全错位或者返回的四边形顶点数超过 4直接报错。原因Canny 阈值太低把答题卡外面的桌面纹理、扫描仪阴影也当成边缘或者答题卡边框颜色特别浅和白色背景对比度不足边框边缘断成了好几截。解决先把 Canny 低阈值从 50 提到 80~100过滤掉细碎纹理。如果边框断裂在 Canny 之后加一次cv2.morphologyEx(edges, cv2.MORPH_CLOSE, kernel)把断开的线段连起来。最后按面积取前五轮廓时不要盲目信第一个遍历每个候选轮廓做approxPolyDP检查顶点数是否为 4。这套组合基本能盖住桌面背景和阴影干扰。5.3 格子整体偏移透视矫正漏了一步固定坐标全部作废现象透视矫正后人眼看着答题卡是正的但切出来的格子全部偏向某一边涂写密度大面积误判。比如第一列格子的左半边切到了上一个选项的尾巴上。原因很多初学者拿到扫描图之后认为“扫描仪出来的图不会歪”直接按固定坐标切格。实际上走纸偏移、扫描头畸变都会让坐标漂移几个像素到几十个像素。另一个原因是透视变换的角点排序出错导致矫正结果整体旋转了 90 度或者镜像翻转。解决切格之前强制走一次four_point_transform不要跳过。如果是手机拍摄图单纯四点透视还不够镜头边缘畸变会让格子越靠边越偏。这时可以用打印黑框角点或者定位二维码作为参考点参考点越多单应矩阵估算越准。5.4 涂写区域全判错固定阈值挡不住整卡亮度漂移现象同一张答题卡上半部分的涂写检测正常下半部分大面积漏判或者反过来下半部分大面积把空白误判成填涂。原因扫描仪前几页和后几页的亮度不一致或者答题卡本身有淡色网格底纹。固定阈值比如 127在亮区会把浅色 2B 铅笔的灰度值算到阈值以上背景被当成涂写在暗区又反着来。解决把二值化从整卡固定阈值改成每个格子独立 Otsu代码就是第四章的fill_ratio写法。再狠一点的话在预处理阶段加cv2.createCLAHE(clipLimit2.0, tileGridSize(8, 8))先做对比度受限的自适应直方图均衡把整张卡的亮度拉均匀再做后续阈值处理。这两步叠加之后亮度漂移带来的误判基本能压下去。5.5 学号识别在训练集上 98%真实扫描件上崩盘现象CNN 在合成测试集上准确率很高换到真实答题卡上数字 3 和 8 互相认错4 和 9 也频繁混淆。原因合成数据的噪声分布和真实扫描环境差距太大。真实答题卡有纸张纹理、灰度不均匀、手写笔的墨水扩散、铅笔灰度深浅不一这些在合成数据集里没有覆盖到。另一个常见原因是训练时输入是 28×28 干净白底黑字推理时直接把切出的格子 resize 到 28×28没有做同样的预处理对齐。解决先从真实答题卡上切几百个手写数字做微调数据哪怕每个数字只有几十个样本也能把合成模型拉回真实分布。如果不方便标注就做一致性校验同一张答题卡扫描两次用模型识别两次学号两次结果不一致的标成“人工复核”。这个手段成本低能挡住绝大多数识别错误。更彻底的方案是把合成数据的增强参数加大旋转从 ±5 度加到 ±10 度背景灰度做整体偏移模拟扫描仪亮度差异。6. 让识别更稳的验证套路对抗式测试与自适应优化系统跑通之后最忌讳的就是“测试集上 99% 就上线”。答题卡识别的风险不在识别率而在个别样本的极低置信度被当成正常结果输出。我的习惯是准备一批对抗式测试图把原始扫描件旋转 3 度、亮度调低 20%、加一层高斯噪声批次跑一遍全流程看结果有没有大面积偏移。这比十折交叉验证更贴近真实使用场景。阈值设置上我建议把判分阈值做成可在配置里动态调整的参数而不是写死。每次拿到一批新答题卡先跑 20 张把每个格子的涂写密度分布打出来看两个峰空白峰和填涂峰的谷值落在哪再改阈值。如果谷值不明显说明扫描质量不够稳定优先查预处理环节而不是硬调阈值。深度学习部分的优化留给推理速度。训练好手写数字模型之后转成 ONNX 格式用onnxruntime在 CPU 上做推理速度比直接跑 PyTorch 快一倍以上。答题卡识别对单张卡的耗时要求不高但对批量处理来说省下的时间就是实打实的效率。手机拍摄的答题卡如果透视特别严重还可以借 OpenCV 的solvePnP做三维姿态估算把纸张平面投影回正视角比单纯四点单应更抗畸变。我自己做这类系统时有一个固定习惯任何参数改动都必须跑完整验证集输出误判清单后再决定要不要保留。不记下旧参数就跑新参数等于给自己挖坑改坏了想回退都没有后悔药。希望这个方向能帮你把阅卷流程真正压到几秒一张而不是停在 Demo 阶段。本文还有配套的精品资源点击获取
返回列表