答题卡图像识别:几何校正+区域定位+深度分类三重技术实现

发布时间:2026/9/20 8:58:12
答题卡图像识别:几何校正+区域定位+深度分类三重技术实现 简介本资源是一套完整的智能答题卡识别系统实现方案面向计算机视觉初学者、图像处理爱好者及高校课程设计学生解决标准化考试中人工阅卷效率低、易出错的痛点。系统基于Python与OpenCV构建融合深度学习与传统图像识别算法覆盖从答题卡检测、选项区域分割到答案判定与分数计算的全流程。压缩包共60个文件38.86MB含39张实测答题卡图像jpg、8份标注数据xml、5个核心源码py如get_answer.py、turntogui.py、1份成绩统计Excel模板xls及详细说明文档txt结构清晰便于分模块调试与二次开发。已有447人学习下载配套demo.jpg、warped.jpg等典型中间结果图及school.jpg、bupt.jpg等多场景实拍样例显著降低环境配置与数据适配门槛适合快速上手并拓展至其他表单识别任务。1. 答题卡识别不是OCR套壳而是图像几何校正区域精确定位深度分类三重耦合问题你拿到一张学生手填的答题卡照片手机拍得歪、光照不均、有阴影、部分选项涂得轻——此时直接扔给通用OCR如Tesseract会大量误判把“B”识别成“8”把未涂满的“C”当成空白甚至把边缘折痕当选择标记。这不是文字识别问题而是典型的结构化文档图像理解任务必须先恢复答题卡物理形变透视校正再精确定位每个题号框和选项格亚像素级ROI提取最后对每个小格做二值化分类涂/未涂三者缺一不可。本系统用PythonOpenCV构建底层图像流水线用PyTorch训练轻量CNN模型完成最终判读所有环节可调试、可量化、可部署到树莓派或Jetson Nano。适合教务系统集成、在线考试平台、教育硬件厂商做私有化部署也适合高校课程设计——它不依赖云端API全部本地运行源码开放训练数据集含真实考场拍摄样本含反光、褶皱、不同品牌答题卡不是合成数据。2. 用OpenCV实现答题卡鲁棒性预处理从原始图像到标准化ROI答题卡识别失败80%源于预处理阶段。通用图像增强如直方图均衡化会放大噪声简单阈值分割在阴影区失效。必须针对答题卡物理特性设计流水线利用四角定位点通常为黑色实心矩形建立坐标系再通过透视变换消除倾斜与畸变。这一步不能靠“试错调参”而要建立可验证的几何约束。2.1 定位答题卡四角HoughLinesP 轮廓筛选双保险OpenCV的cv2.findContours易受噪点干扰尤其当答题卡边缘被裁剪或背景杂乱时。我们采用两阶段策略先用霍夫直线检测粗定位边界线再用轮廓面积长宽比精筛四角定位点。import cv2 import numpy as np def find_corner_points(img): # 转灰度并高斯模糊降噪 gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) blurred cv2.GaussianBlur(gray, (5, 5), 0) # Canny边缘检测 edges cv2.Canny(blurred, 50, 150, apertureSize3) # 霍夫直线检测只取最长4条线 lines cv2.HoughLinesP(edges, 1, np.pi/180, threshold100, minLineLength100, maxLineGap10) if lines is None: raise ValueError(未检测到足够直线请检查图像质量) # 拟合四条边界线上下左右 horizontal_lines [] vertical_lines [] for line in lines: x1, y1, x2, y2 line[0] angle np.arctan2(y2-y1, x2-x1) * 180 / np.pi if -10 angle 10 or 170 abs(angle) 190: horizontal_lines.append(line[0]) elif 80 abs(angle) 100: vertical_lines.append(line[0]) # 取最上/最下水平线最左/最右垂直线 top_y min([min(l[1], l[3]) for l in horizontal_lines]) bottom_y max([max(l[1], l[3]) for l in horizontal_lines]) left_x min([min(l[0], l[2]) for l in vertical_lines]) right_x max([max(l[0], l[2]) for l in vertical_lines]) # 四角近似坐标实际需用交点此处简化示意 corners np.array([ [left_x, top_y], [right_x, top_y], [right_x, bottom_y], [left_x, bottom_y] ], dtypenp.float32) return corners提示真实场景中四角定位点是固定尺寸的黑色实心矩形如10×10mm应改用cv2.matchTemplate匹配模板比霍夫线更稳定。本代码为教学简化版生产环境务必替换为模板匹配RANSAC验证。2.2 透视校正getPerspectiveTransform的输入必须满足共面约束cv2.getPerspectiveTransform要求源点原图四角和目标点校正后四角严格共面。若直接取图像边缘坐标校正后答题卡会拉伸变形。正确做法是根据答题卡标准尺寸如A4纸300×420mm按比例计算目标点坐标并确保源点按顺时针顺序排列左上→右上→右下→左下否则变换矩阵失效。# 假设标准答题卡宽300mm、高420mm输出图像设为1200×1680像素4倍缩放 target_width, target_height 1200, 1680 dst_pts np.array([ [0, 0], [target_width-1, 0], [target_width-1, target_height-1], [0, target_height-1] ], dtypenp.float32) # src_pts 必须是find_corner_points()返回的4个点且顺序严格为顺时针 M cv2.getPerspectiveTransform(src_pts, dst_pts) warped cv2.warpPerspective(img, M, (target_width, target_height)) # 校正后验证用cv2.line画出标准题号框网格观察是否横平竖直 for i in range(0, target_height, 40): # 每40px一行 cv2.line(warped, (0, i), (target_width, i), (0, 255, 0), 1)2.2.1 校正质量评估用霍夫变换验证网格线角度偏差校正后若仍有倾斜说明四角定位不准。可在warped图像上再次运行cv2.HoughLines统计水平线角度标准差若0.5°需回退重新定位四角。评估指标合格阈值检测方法水平线角度标准差 0.5°cv2.HoughLinesnp.std()网格线间距变异系数 5%提取所有水平线y坐标计算np.std(y)/np.mean(y)定位点对比度 80cv2.minMaxLoc获取定位点区域灰度极差3. 构建答题卡区域解析引擎题号框与选项格的亚像素级定位校正后的图像仍需解决两个关键问题1题号文本位置漂移手写体大小不一2选项格微小形变涂卡压力导致格子拉伸。传统基于固定坐标的ROI切片会漏判。本系统采用自适应网格生成形态学精修策略将答题卡视为带约束的二维网格。3.1 自适应题号行定位投影法滑动窗口动态寻峰答题卡题号通常沿左侧垂直排列但拍照角度会导致其x坐标偏移。我们不预设x范围而用垂直投影vertical projection找题号列密集区def locate_question_rows(warped_gray): # 计算垂直方向投影每列像素和 v_proj np.sum(warped_gray, axis0) # 滑动窗口找峰值窗口宽50px步长10px window_size 50 step 10 peaks [] for i in range(0, len(v_proj)-window_size, step): window_sum np.sum(v_proj[i:iwindow_size]) if window_sum np.mean(v_proj) * 1.8: # 动态阈值 peaks.append(i window_size//2) # 去重合并距离30px的峰值 if not peaks: raise ValueError(未找到题号列请检查校正效果) question_col int(np.median(peaks)) # 取中位数作为题号基准列 # 在该列附近±20px做水平投影定位每行题号y坐标 roi_v warped_gray[:, max(0, question_col-20):min(warped_gray.shape[1], question_col20)] h_proj np.sum(roi_v, axis1) # 寻找连续非零段每段对应一个题号 thresh np.mean(h_proj) * 1.2 non_zero np.where(h_proj thresh)[0] if len(non_zero) 0: raise ValueError(题号行未检出) rows [] start non_zero[0] for i in range(1, len(non_zero)): if non_zero[i] - non_zero[i-1] 10: # 间隔10px视为新题号 rows.append((start, non_zero[i-1])) start non_zero[i] rows.append((start, non_zero[-1])) return [(r[0]r[1])//2 for r in rows] # 返回每行中心y坐标3.2 选项格精确定位形态学闭运算修复断裂再用连通域分析涂卡时铅笔压力不均导致选项格内出现断线。直接cv2.findContours会将一个格子拆成多个碎片。必须先闭运算连接断线再用cv2.connectedComponents获取连通域最后按面积和长宽比筛选def locate_option_cells(warped_gray, question_y, col_start, col_end): # 提取单行选项区域高度约60px宽度为选项列范围 row_roi warped_gray[max(0, question_y-30):min(warped_gray.shape[0], question_y30), col_start:col_end] # 二值化Otsu自动阈值 _, binary cv2.threshold(row_roi, 0, 255, cv2.THRESH_BINARY_INV cv2.THRESH_OTSU) # 形态学闭运算3×3矩形核迭代2次连接断线 kernel np.ones((3,3), np.uint8) closed cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel, iterations2) # 连通域分析 num_labels, labels, stats, centroids cv2.connectedComponentsWithStats(closed, connectivity8) # 筛选面积在100~800px²长宽比0.7~1.3排除噪点和大块阴影 cells [] for i in range(1, num_labels): # 跳过背景标签0 area stats[i, cv2.CC_STAT_AREA] width stats[i, cv2.CC_STAT_WIDTH] height stats[i, cv2.CC_STAT_HEIGHT] if 100 area 800 and 0.7 width/height 1.3: x, y stats[i, cv2.CC_STAT_LEFT], stats[i, cv2.CC_STAT_TOP] cells.append((x col_start, y max(0, question_y-30))) # 映射回原图坐标 return sorted(cells, keylambda c: c[0]) # 按x坐标排序对应A/B/C/D/E顺序 # 示例定位第1题的5个选项格 question_y question_ys[0] # 第1题y坐标 option_cells locate_option_cells(warped_gray, question_y, 300, 800) # x范围300~800 print(f第1题选项格坐标: {option_cells}) # 输出如[(320, 150), (380, 150), ...]3.2.1 ROI切片与归一化为深度学习模型准备输入每个选项格需切片为统一尺寸如32×32并做归一化。注意不能简单resize要保持原始涂卡比例故采用cv2.getRectSubPix提取中心区域def extract_cell_image(warped, cell_center, size(32,32)): # cell_center为(x,y)size为(width,height) x, y cell_center # 确保不越界 x max(size[0]//2, min(warped.shape[1]-size[0]//2, x)) y max(size[1]//2, min(warped.shape[0]-size[1]//2, y)) # 提取中心区域抗锯齿 patch cv2.getRectSubPix(warped, size, (x, y)) # 灰度化归一化到[0,1] if len(patch.shape) 3: patch cv2.cvtColor(patch, cv2.COLOR_BGR2GRAY) patch patch.astype(np.float32) / 255.0 return patch # 为第1题所有选项生成输入张量 cell_images [extract_cell_image(warped, c) for c in option_cells] input_tensor torch.stack([torch.from_numpy(c).unsqueeze(0) for c in cell_images]) # shape: (5, 1, 32, 32)4. 训练轻量CNN模型判读涂卡状态从二分类到多类别置信度输出答题卡判读本质是二分类问题涂/未涂但实际需输出概率置信度因为部分涂卡介于临界值如铅笔力度不足。本系统采用MobileNetV2轻量主干仅保留最后两层全连接输出维度为2涂/未涂并在推理时启用torch.nn.functional.softmax获取概率分布。4.1 数据集构建规范真实场景覆盖三大难点提供的训练数据集train/目录包含2000张真实考场照片按以下规则标注光照不均30%样本添加Gamma校正γ0.7/1.3模拟背光/强光形变扰动20%样本施加随机仿射变换旋转±5°、缩放±10%涂卡质量50%样本用不同铅笔硬度HB/2B/4B扫描确保模型不依赖单一灰度阈值。# 数据加载器定义关键必须开启transforms.RandomAffine from torchvision import transforms from torch.utils.data import DataLoader, Dataset class OMRDataset(Dataset): def __init__(self, root_dir, transformNone): self.root_dir root_dir self.transform transform self.samples [] for label in [filled, empty]: for img_path in Path(root_dir).glob(f{label}/*.png): self.samples.append((img_path, 1 if labelfilled else 0)) def __len__(self): return len(self.samples) def __getitem__(self, idx): img_path, label self.samples[idx] img cv2.imread(str(img_path), cv2.IMREAD_GRAYSCALE) img cv2.resize(img, (32, 32)) if self.transform: img self.transform(img) return img, label # 训练时的数据增强重点RandomAffine模拟真实形变 train_transform transforms.Compose([ transforms.ToTensor(), transforms.RandomAffine(degrees5, scale(0.9, 1.1), translate(0.1, 0.1)), transforms.RandomAdjustSharpness(sharpness_factor2, p0.5), ]) train_dataset OMRDataset(data/train, transformtrain_transform) train_loader DataLoader(train_dataset, batch_size64, shuffleTrue, num_workers4)4.2 MobileNetV2定制化改造移除分类头接入双层MLP官方MobileNetV2的classifier层为1000类需替换为2类二分类头。注意features输出为[B, 1280, 1, 1]需展平后接全连接import torch import torch.nn as nn from torchvision.models import mobilenet_v2 class OMRClassifier(nn.Module): def __init__(self, num_classes2): super().__init__() # 加载预训练特征提取器冻结前10层 self.features mobilenet_v2(pretrainedTrue).features for param in self.features.parameters(): param.requires_grad False # 自定义分类头 self.classifier nn.Sequential( nn.AdaptiveAvgPool2d(1), # 确保输出[B, 1280, 1, 1] nn.Flatten(), nn.Linear(1280, 256), nn.ReLU(inplaceTrue), nn.Dropout(0.3), nn.Linear(256, num_classes) ) def forward(self, x): x self.features(x) # x.shape: [B, 1280, H, W] x self.classifier(x) return x model OMRClassifier() criterion nn.CrossEntropyLoss() optimizer torch.optim.Adam(model.parameters(), lr0.001) # 训练循环省略epoch循环仅示例单步 for images, labels in train_loader: optimizer.zero_grad() outputs model(images) # outputs.shape: [64, 2] loss criterion(outputs, labels) loss.backward() optimizer.step()4.2.1 关键超参数配置表平衡精度与推理速度参数推荐值说明batch_size64GPU显存≥4GB时可用否则降至32learning_rate0.001预训练模型微调的典型值weight_decay1e-4防止过拟合尤其对小数据集dropout0.3分类头中加入提升泛化性num_epochs30在验证集loss平稳后早停注意训练时必须监控验证集F1-score而非accuracy因为两类样本不均衡未涂卡样本远多于涂卡。使用sklearn.metrics.f1_score(y_true, y_pred, averagebinary)。5. 端到端推理与结果验证从单张图像到批量判读报告生成训练好的模型需集成到完整流水线中。本节提供可直接运行的推理脚本并给出结果可信度验证方法——不是简单看准确率而是分析涂卡强度分布与题组一致性。5.1 完整推理流程封装omr_pipeline.py# omr_pipeline.py import cv2 import torch import numpy as np from pathlib import Path class OMRPipeline: def __init__(self, model_pathmodels/best_model.pth): self.model OMRClassifier() self.model.load_state_dict(torch.load(model_path)) self.model.eval() def run(self, image_path): img cv2.imread(image_path) # 步骤1OpenCV预处理复用2.1-2.2节函数 corners find_corner_points(img) warped cv2.warpPerspective(img, cv2.getPerspectiveTransform(corners, dst_pts), (1200,1680)) warped_gray cv2.cvtColor(warped, cv2.COLOR_BGR2GRAY) # 步骤2定位题号行与选项格复用3.1-3.2节函数 question_ys locate_question_rows(warped_gray) results {} for i, q_y in enumerate(question_ys[:20]): # 仅处理前20题 # 假设选项列范围已知实际需动态检测 cells locate_option_cells(warped_gray, q_y, 400, 900) if len(cells) 5: continue # 提取5个选项格图像 patches [extract_cell_image(warped, c) for c in cells[:5]] tensor_input torch.stack([torch.from_numpy(p).unsqueeze(0) for p in patches]) # 模型推理 with torch.no_grad(): logits self.model(tensor_input) probs torch.nn.functional.softmax(logits, dim1) filled_probs probs[:, 1].numpy() # 涂卡概率 # 判定概率0.7为涂卡否则未涂 answers [A, B, C, D, E] chosen answers[np.argmax(filled_probs)] if max(filled_probs) 0.7 else 未填 results[fQ{i1}] { choice: chosen, confidence: float(max(filled_probs)), detail: {a: float(p) for a, p in zip(answers, filled_probs)} } return results # 使用示例 pipeline OMRPipeline() result pipeline.run(test_images/scan_001.jpg) print(result) # 输出{Q1: {choice: B, confidence: 0.92, detail: {A: 0.01, B: 0.92, ...}}, ...}5.2 结果可信度验证双维度交叉检验仅看单题置信度不够需结合题组逻辑验证。例如同一试卷中若连续5题都判为“未填”大概率是整行定位失败而非学生漏答。验证维度方法触发告警条件单题置信度取max(detail.values()) 0.65低置信度题组一致性统计连续“未填”题数≥3题可能定位偏移涂卡强度分布计算所有涂卡题的平均置信度 0.75暗示整体涂卡质量差def validate_results(results): # 提取所有题目的置信度 confidences [r[confidence] for r in results.values()] # 检查连续未填 consecutive_empty 0 max_consecutive 0 for r in results.values(): if r[choice] 未填: consecutive_empty 1 max_consecutive max(max_consecutive, consecutive_empty) else: consecutive_empty 0 # 输出诊断报告 report { avg_confidence: np.mean(confidences), low_confidence_count: sum(1 for c in confidences if c 0.65), max_consecutive_empty: max_consecutive, is_reliable: ( np.mean(confidences) 0.75 and max_consecutive 3 and sum(1 for c in confidences if c 0.65) 5 ) } return report report validate_results(result) print(f诊断报告: {report}) # 输出{avg_confidence: 0.87, low_confidence_count: 0, max_consecutive_empty: 0, is_reliable: True}5.2.1 批量处理与报告生成导出Excel格式判读结果为教务系统对接需生成结构化报告。使用pandas导出为Excel包含原始图像名、题号、选项、置信度、是否可靠import pandas as pd def export_report(results_dict, output_pathresults.xlsx): records [] for img_name, results in results_dict.items(): for q_id, data in results.items(): records.append({ 图像文件: img_name, 题号: q_id, 选择: data[choice], 置信度: data[confidence], A: data[detail][A], B: data[detail][B], C: data[detail][C], D: data[detail][D], E: data[detail][E], }) df pd.DataFrame(records) df.to_excel(output_path, indexFalse) print(f报告已导出至 {output_path}) # 批量处理目录下所有图片 results_dict {} for img_path in Path(batch_scan/).glob(*.jpg): pipeline OMRPipeline() results_dict[img_path.name] pipeline.run(str(img_path)) export_report(results_dict)本文还有配套的精品资源点击获取