简介:这套基于数字图像处理与机器学习技术的理工科选择题自动识别与判分系统,面向教育考试阅卷场景,运用OpenCV与HOGSVM方法完成答题卡标记识别,支持批量处理学生答题卡并自动计算得分,可有效替代传统人工阅卷,提升效率与评分一致性。压缩包内共1274个文件,约22.33MB,以1259张jpg图像样本为主,并包含C++源码、Visual Studio工程文件、XML配置及docx说明文档,覆盖图像预处理、HOG特征提取、SVM分类、主程序调用等完整实现环节,便于对照工程实际复现。系统涉及图像扫描、预处理、特征提取、识别与判分等模块,读者可从中了解整套流水线的设计思路。当前已有89人学习下载,适合计算机视觉开发者、教育信息化人员及相关专业学生作为参考。通过该资源,可获得可运行的答题卡识别工程、批量测试图像集和项目文档,深入理解数字图像处理与机器学习结合的实际落地方式,也可作为毕业设计或课设项目起点。
1. 理工科选择题答题卡识别,为什么先选 HOG+SVM 而不是深度学习
高考、月考、随堂测验,理工科选择题占了卷面的很大一块,批改起来费眼费时间。用 OpenCV 做图像预处理、用 HOG+SVM 做选项检测和自动判分,是一个老派但依然好用的方案:训练数据不用很多,几百个选项框就能跑;CPU 上处理一张卡不到一秒;判错的样本还能可视化出来看是哪个选项的哪个特征出了问题。它解决的是“拍歪了、涂浅了、批量读卡”这一整条链路的问题,适合已经有 Python 和 OpenCV 基础、想在教育阅卷场景里快速落地一个识别工具的工程师。深度学习不是不行,但在数据量小、解释性要求高的场合,HOG+SVM 反而更不容易翻车。
2. 答题卡图像预处理:从灰度化到透视校正的完整流程
答题卡识别的第一个坑不在识别,在成像。你拿到的可能是一张扫描件,也可能是一张手机拍的考场照片。手机拍的答题卡十有八九是歪的,四个角还带点透视变形。这个阶段不处理干净,后面所有选项框的位置都会偏移,别说 HOG 特征,就算换成深度学习也一样误判。所以预处理的目标很明确:把任意角度拍摄的答题卡变成一张位置固定、方向水平、光照均匀的标准图像。
2.1 为什么先做灰度化和高斯模糊:彩色图在答题卡识别里是个干扰
先别急着提机器学习,数字图像处理的第一步是把图像简化。答题卡通常是白底黑字,选项框是空心矩形,涂写区域是黑色笔迹。这些信息在灰度图里就已经完整保留了,彩色通道在后续计算里不出力,还会让光照和阴影的影响放大。我的习惯是拿到图先灰度化,再做一次高斯模糊。高斯模糊的核大小一般取 5x5,太大容易把涂写笔迹的边界抹平,太小又滤不掉扫描噪点。
import cv2 import numpy as np def load_and_preprocess(image_path): # 读入原始图像,OpenCV 默认 BGR 通道顺序 img = cv2.imread(image_path) if img is None: raise ValueError(f"无法读取图像: {image_path}") # BGR 转灰度,丢掉彩色信息 gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 高斯模糊,降低扫描噪点和纸张纹理的干扰 blurred = cv2.GaussianBlur(gray, (5, 5), 0) return blurred这段代码做了三件事:读图、转灰度、模糊。高斯模糊的 (5, 5) 是卷积核的宽高,0 是高斯标准差,传 0 让 OpenCV 根据核大小自动推算。灰度化之后图像从三通道变成单通道,后续 Canny 边缘检测和轮廓查找都只需处理一个维度,内存和时间都省下不少。
2.2 透视校正:用四点变换把歪着拍的答题卡拉正
答题卡识别最怕的不是模糊,是透视变形。摄像头放在桌子正上方的时候还好,稍微侧一点,答题卡就变成梯形,选项框不再水平对齐,直接用固定坐标切框会全部错位。解决办法是找到答题卡的外边框,取四个角点,做透视变换。找边框用 Canny 边缘检测加轮廓查找。Canny 的阈值我一般设 (50, 150),低于 50 的梯度被认为是噪声,高于 150 的确定是边缘,中间部分由 OpenCV 根据连通性补齐。
def find_document_contour(blurred): # Canny 边缘检测,返回二值边缘图 edges = cv2.Canny(blurred, 50, 150) # 查找外轮廓,只取最外层,压缩水平/垂直方向的冗余点 contours, _ = cv2.findContours( edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE ) # 按轮廓面积从大到小排序,答题卡通常是画面里最大的四边形 contours = sorted(contours, key=cv2.contourArea, reverse=True)[:5] for cnt in contours: # 计算轮廓周长,用于多边形逼近的精度控制 perimeter = cv2.arcLength(cnt, True) # 以周长的 2% 为精度,把轮廓拟合成多边形 approx = cv2.approxPolyDP(cnt, 0.02 * perimeter, True) if len(approx) == 4: return approx raise RuntimeError("未找到答题卡边界,请检查拍摄角度和背景")approxPolyDP 是轮廓拟合的核心。0.02 是拟合精度,这个值越小拟合越严格,越大越容易得到粗线条的四边形。答题卡边缘清晰时,0.02 到 0.05 之间都行。如果现场发现边框经常拟合出五边形,可以把它调到 0.01;如果拟合出的四边形在后续变换后仍然歪斜,多半是精度太大导致角点位置不准。
拿到四个角点之后,接下来要按顺序排列:左上、右上、右下、左下。这四个点的顺序不能搞错,不然透视变换矩阵会把图像翻转。排序方法很简单,先按 y 坐标总和分成上下两组,再在每组里按 x 排序。排好序之后用 getPerspectiveTransform 计算变换矩阵,用 warpPerspective 把整张图拉正。
def order_points(pts): # pts 是 approx 的四个点,形状为 (4, 2) rect = np.zeros((4, 2), dtype=np.float32) # 左上:x + y 最小;右下:x + y 最大 s = pts.sum(axis=1) rect[0] = pts[np.argmin(s)] rect[2] = pts[np.argmax(s)] # 右上:x - y 最小;左下:x - y 最大 diff = np.diff(pts, axis=1).flatten() rect[1] = pts[np.argmin(diff)] rect[3] = pts[np.argmax(diff)] return rect def warp_perspective(img, pts): rect = order_points(pts) (tl, tr, br, bl) = rect # 目标宽度取上下边距离的较大者,目标高度取左右边距离的较大者 width_top = np.linalg.norm(br - bl) width_bottom = np.linalg.norm(tr - tl) height_left = np.linalg.norm(tr - br) height_right = np.linalg.norm(tl - bl) 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] ], dtype=np.float32) # 由四个原始角点和四个目标角点计算变换矩阵 M = cv2.getPerspectiveTransform(rect, dst) warped = cv2.warpPerspective(img, M, (max_width, max_height)) return warpedwarpPerspective 之后的图像,答题卡的边框和水平轴就对齐了。到这里,预处理的主流程就通了。需要注意,这个校正过程依赖答题卡外边框能被完整拍到。如果拍摄时背景和答题卡都是白色,Canny 之后很可能分不清边界,这时候在拍摄阶段垫一张深色垫板是最省事的解决方式。
2.3 选项区域定位:按行列切分,把每个选项框变成独立 ROI
答题卡拉正之后,接下来要做的是把每一道题的选项框切出来。标准理工科答题卡一般是:左侧一列题号,右侧 ABCD 四个选项竖着排。切分方法有两种:一种是用固定坐标的模板,另一种是根据答题卡上的定位标记动态计算。模板法适合印刷质量稳定、扫描位置固定的场景;动态计算适合手机拍摄这种每次位置都有轻微偏移的场景。我建议做动态的,否则换一次答题卡排版模板就废了。
动态切分的第一步是找表格线。检测水平线和垂直线的做法是用不同方向的核做形态学运算,水平线用宽的横核,垂直线用高的竖核。检测到直线之后,按线条的投影分布把答题区域的行列边界算出来,再按等间距切出每个选项框。
def extract_option_boxes(warped, rows=20, cols=4): # rows: 题目数量,cols: 选项数量,理工科通常为 4 h, w = warped.shape # 每个选项框的宽高按答题卡有效区域均匀划分 box_w = w // cols box_h = h // rows boxes = [] for r in range(rows): row_boxes = [] for c in range(cols): x1 = c * box_w y1 = r * box_h x2 = x1 + box_w y2 = y1 + box_h roi = warped[y1:y2, x1:x2] row_boxes.append(roi) boxes.append(row_boxes) return boxes这段代码用了最朴素的均匀切分方式,前提是题目数量和选项数量是已知的。理工科选择题的排版通常很规整,可行。但如果答题卡带题号列或用 A4 双栏排版,就要先测出题号列宽度,把有效区域裁掉再切分。均匀切分的风险在于第一行第一列如果带页码或二维码,会污染第一个选项框,后面做训练数据时要把这种脏样本筛掉。
3. 用 HOG+SVM 训练选项检测模型:特征提取、样本标注与参数调节
选项框切出来之后,接下来的问题就变成了一个二分类问题:这个框是“已涂写”还是“未涂写”。图像特征选什么、分类器用哪个,这是本项目的技术分水岭。用深度学习当然可以做,但答题卡检测这种任务样本量不会太大,通常只有几百道题、几千个选项框,而且要求误判样本能被人理解——判错了要能说清是涂得太浅还是阴影干扰。HOG+SVM 恰好具备这两个特点。
3.1 HOG 特征在答题卡场景里为什么有效:它描述的是“方向梯度”
HOG 的全称是 Histogram of Oriented Gradients,方向梯度直方图。它的基本思想是:图像里的物体边缘有方向和强度,把图像分成小格子,统计每个格子里梯度方向的分布,就能用一组向量描述这个区域的形状。涂黑的选项框里,笔迹边缘杂乱无章,梯度方向分布很零散;空白选项框里只有印刷边框的横线和竖线,梯度方向集中在水平和垂直两个方向。这两类样本在 HOG 特征空间里天然分得开。
用 OpenCV 官方 HOGDescriptor 提取特征,参数一般不用全部理解也能跑起来,但有几个参数直接决定了特征维度。下面是一个在实际工程里调过的配置:
from skimage import exposure hog = cv2.HOGDescriptor( winSize=(64, 32), # 检测窗口大小,需与选项框缩放尺寸一致 blockSize=(16, 16), # block 大小,影响局部对比度归一化的范围 blockStride=(8, 8), # block 滑动步长,越小特征维度越高 cellSize=(8, 8), # cell 大小,每个 cell 统计一个直方图 nbins=9 # 梯度方向分箱数 )winSize 要和你打算喂给模型的选项框尺寸完全一致。切出来的 ROI 大小不固定,我一般先统一 resize 到 64x32。blockSize、cellSize、nbins 三者的关系决定了特征维度:一个 64x32 的窗口,按 8x8 cell 算,水平方向 8 个 cell、垂直方向 4 个 cell;一个 block 含 2x2 个 cell,特征维度会比较高,但分类效果稳定。如果需要提速,可以把 blockSize 调到 16x8,维度大概能降一半,精度会差一点点。
3.2 标注样本:写一个带 GUI 的半自动标注脚本,别手动数像素
训练 HOG+SVM 需要正负样本,正样本是已涂选项框,负样本是空白选项框。很多第一次做的同学会卡在“怎么搞到样本”。最靠谱的办法是拿真实答题卡扫描件,先跑一遍第 2 章的预处理和选项切分,然后按顺序把每个选项框显示出来,人工按一个键打标签。OpenCV 自带的窗口函数就能做,不需要额外装 GUI 库。
import cv2 import os def label_samples(boxes, save_dir, start_index=0): os.makedirs(os.path.join(save_dir, 'positive'), exist_ok=True) os.makedirs(os.path.join(save_dir, 'negative'), exist_ok=True) index = start_index for row_idx, row_boxes in enumerate(boxes): for col_idx, roi in enumerate(row_boxes): roi_resized = cv2.resize(roi, (64, 32)) # 显示窗口,按 p 标记为已涂(positive),按 n 标记为未涂(negative) cv2.imshow('option box', roi_resized) key = cv2.waitKey(0) if key == ord('p'): cv2.imwrite( os.path.join(save_dir, 'positive', f'{index:05d}.jpg'), roi_resized ) index += 1 elif key == ord('n'): cv2.imwrite( os.path.join(save_dir, 'negative', f'{index:05d}.jpg'), roi_resized ) index += 1 elif key == 27: # ESC 退出 cv2.destroyAllWindows() return index cv2.destroyAllWindows() return index标注脚本本身不复杂,但有一个血泪经验:一定要把 ROI 统一缩放成和 HOG 的 winSize 一样再保存,否则后面提取特征时要么重新缩放、要么训练输入尺寸不匹配报错。另一个重要细节是 index 要接续,不要每次跑都从 0 开始,否则会把不同批次标注的样本覆盖掉。标注数量的话,正样本和负样本每一类至少有 200 张起步,能把置信度调稳;少于 100 张的话,SVM 很容易过拟合到某一张答题卡的光照模式上。
3.3 训练 SVM:C 值别乱调,先用默认值再微调
样本准备好了,训练过程其实很简洁。OpenCV 的机器学习模块里封装了 SVM,不需要额外装 scikit-learn,训练完直接保存成 xml 文件,部署时用同一套 HOG 参数加载即可。
import cv2 import numpy as np import glob def extract_hog_features(hog, file_list): features = [] labels = [] for file_path in file_list: img = cv2.imread(file_path, cv2.IMREAD_GRAYSCALE) img = cv2.resize(img, (64, 32)) feature = hog.compute(img).flatten() features.append(feature) if 'positive' in file_path: labels.append(1) else: labels.append(0) return np.array(features), np.array(labels) # 正样本和负样本路径 pos_files = glob.glob('samples/positive/*.jpg') neg_files = glob.glob('samples/negative/*.jpg') X_pos, y_pos = extract_hog_features(hog, pos_files) X_neg, y_neg = extract_hog_features(hog, neg_files) X = np.vstack([X_pos, X_neg]).astype(np.float32) y = np.hstack([y_pos, y_neg]).astype(np.int32) # 创建线性 SVM 分类器 svm = cv2.ml.SVM_create() svm.setType(cv2.ml.SVM_C_SVC) svm.setKernel(cv2.ml.SVM_LINEAR) svm.setC(1.0) svm.setTermCriteria((cv2.TERM_CRITERIA_MAX_ITER, 1000, 1e-6)) svm.train(X, cv2.ml.ROW_SAMPLE, y) svm.save('answer_sheet_svm.xml')这里核函数选的是 LINEAR 线性核,不是 RBF 径向基核。原因是特征维度已经上千,线性核在多数情况下足够划分,而且推理速度快、不容易过拟合。C 是误分类惩罚系数,默认 1.0 通常就能得到不错的基线。如果训练集准确率高但测试集翻车,把 C 调小到 0.1;如果训练集本身就不收敛,再往大调。SVM_C_SVC 表示多分类支持向量分类器,但这里只有两个类别,所以它其实是一个二分类器,标签只用 0 和 1。
训练完别急着部署,先拿训练集里的一部分做交叉验证。我习惯在标注阶段就留存一部分样本:标注第三批的时候不要放进训练集,而是单独放到 eval 文件夹,专门用来测泛化。这一步能帮你识别是不是过拟合到了某张答题卡的纸张反光上。
4. 判分逻辑与批量处理的工程化设计:从单张识别到批量阅卷
模型训练好了只是第一步。真实阅卷场景里,一张答题卡有几十道题,一个班有几十张卡,你得设计一套稳定可追溯的判分流程。这一章解决两件事:怎么管理“哪道题对应哪个选项框”的映射关系,怎么把识别结果自动折算成分数并导出。
4.1 判分数据模型:题号、选项、标准答案之间的映射关系
忘掉像素和特征,先想清楚数据模型。一张理工科答题卡可以抽象成三层结构:第一层是题目列表,第二层是每道题的选项,第三层是每道题的标准答案和分值。如果只用 list 存识别结果,后期想改标准答案或者统计每道题的得分分布,就得改代码。我一般用一个简单的配置类来管理:
from dataclasses import dataclass, field @dataclass class Question: number: int options: list = field(default_factory=list) # ['A', 'B', 'C', 'D'] answer_index: int = -1 # 标准答案的索引,A=0, B=1 依次类推 score: float = 1.0 # 每题分值 selected_index: int = -1 # 模型识别出的学生所选选项索引 def is_correct(self): return self.selected_index == self.answer_index为什么要用 index 而不是直接用 “A/B/C/D” 字符串?因为 SVM 模型的输出是选项框的序号,比如第 2 行第 1 列是某道题的选项 A。如果用字符串,还得维护一张 label 到字母的映射表,容易在边界情况写错。用 index,映射只在读答题卡配置时做一次,后续判分逻辑里全是整数比较,简单且不容易犯糊涂。
4.2 批量处理流程:目录遍历、单卡预测、结果汇总
批量处理的核心是一个循环:遍历目录下的所有答题卡图片,对每张图依次执行预处理 -> 切分选项框 -> HOG 特征提取 -> SVM 预测 -> 判分 -> 结果写入列表。循环完,把结果统一导出成 CSV 或 Excel。这里有一个说明:不要每张卡都保存中间结果图,磁盘会很快被占满;只保存识别失败的卡,作为人工复核材料。
import os import csv def predict_option_boxes(model, boxes, hog): results = [] for row in boxes: row_pred = [] for roi in row: roi_resized = cv2.resize(roi, (64, 32)) feature = hog.compute(roi_resized).flatten().astype(np.float32) _, pred = model.predict(feature.reshape(1, -1)) row_pred.append(int(pred[0][0])) results.append(row_pred) return results def batch_score(sheet_dir, questions, output_csv='scores.csv'): svm = cv2.ml.SVM_load('answer_sheet_svm.xml') hog = cv2.HOGDescriptor( winSize=(64, 32), blockSize=(16, 16), blockStride=(8, 8), cellSize=(8, 8), nbins=9 ) with open(output_csv, 'w', newline='', encoding='utf-8-sig') as f: writer = csv.writer(f) writer.writerow(['student_id', 'total_score']) files = sorted([f for f in os.listdir(sheet_dir) if f.endswith('.jpg')]) for file_name in files: image_path = os.path.join(sheet_dir, file_name) blurred = load_and_preprocess(image_path) contour = find_document_contour(blurred) warped = warp_perspective(blurred, contour.reshape(4, 2)) boxes = extract_option_boxes(warped, rows=len(questions), cols=4) predictions = predict_option_boxes(svm, boxes, hog) total = 0.0 for q_idx, question in enumerate(questions): # 预测结果是 1 表示涂写,0 表示未涂写 selected = predictions[q_idx].index(1) if 1 in predictions[q_idx] else -1 question.selected_index = selected if question.is_correct(): total += question.score writer.writerow([file_name.replace('.jpg', ''), total])这个环节容易忽略编码问题。CSV 里写姓名和题号时,用 encoding='utf-8-sig' 才能让 Excel 直接打开不乱码,用纯 utf-8 在部分旧版 Windows 的 WPS 里会显示成乱码。文件排序用 sorted 但只按文件名排,如果文件名是 1.jpg、10.jpg、2.jpg,顺序会不对,建议上传前统一命名为 001.jpg、002.jpg。
4.3 判分逻辑细节:多选、漏选、错选的计分规则
理工科选择题不全是单选题,判断题、多选题常见。判分逻辑得先统一成“选出的选项索引集合”再计分。多选漏选的计分标准有几种,常见做法是全对才满分,漏选得部分分。实现时注意边界:多选题选了 2 个,标准答案是 2 个,但其中 1 个错——这属于“错选”,不是“漏选”,得分规则不一样。
for q_idx, question in enumerate(questions): if question.is_multiple_choice: # 预测结果转换成选项索引集合,比如 {0, 2} selected_set = { idx for idx, val in enumerate(predictions[q_idx]) if val == 1 } answer_set = set(question.answer_indices) if selected_set == answer_set: total += question.score else: # 错选和漏选都不得分,可根据需求调整为部分分 total += 0.0 else: selected = -1 if 1 in predictions[q_idx]: selected = predictions[q_idx].index(1) question.selected_index = selected if question.is_correct(): total += question.score判分逻辑本身就几行 if-else,容易出问题的地方是把“is_correct”这种状态混进分数计算里、又想在界面上展示状态,后来发现状态被覆盖。我的做法是判分前先把识别结果全部保存在 Question 对象里,判分时只读不写。分数算错了,还能回去查每一题的识别状态,不用重新跑整个批量流程。这是给自己留的“后悔药”。
5. 避坑:答题卡识别最常见的 5 个翻车点与排查方案
跑通流程不难,难的是在所有边角场景下不翻车。第五章集中写我在实际测试里反复踩过的坑,按“现象 -> 原因 -> 解决”的顺序,一条条对应。这些问题在你自己做的时候大概率也会遇到,提前避开能省下一整天。
5.1 涂卡涂得太浅,SVM 把已涂选项判成未作答
现象:学生在答题卡上用普通 2B 铅笔快速涂卡,涂痕不均匀,中间有明显的白色缝隙。SVM 模型预测的结果是 0(未涂写),导致整道题被判定为漏选,分数大面积丢失。
原因:HOG 特征在浅涂区域提取到的梯度分布和空白选项框非常接近。2B 铅笔的石墨反光会让浅涂区域在灰度上介于白纸和深色笔迹之间,SVM 的决策边界在这一段比较模糊。
解决:模型之外加一个像素统计兜底。对每个选项框计算黑色像素占比(或灰度均值),设定一个阈值作为第二通道,SVM 预测为 0 但黑色像素占比超过阈值的,重新判为已涂。阈值一般取 5% 到 8%,和纸张底色、扫描亮度有关,先跑几张有代表性的卡试出来再固化。
def pixel_fallback(roi, svm_pred, threshold=0.06): # 把 ROI 转成灰度并二值化,黑色像素占比超过阈值视为涂写 gray = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY) _, binary = cv2.threshold(gray, 125, 255, cv2.THRESH_BINARY_INV) black_ratio = np.count_nonzero(binary) / binary.size if svm_pred == 0 and black_ratio > threshold: return 1 return svm_pred5.2 双选题当单选题判,统计逻辑导致整张卡分数错乱
现象:多选题的标准答案是 AB,学生涂了 AB,SVM 把 A 和 B 都判为 1,但判分逻辑写的还是“index(1) 取第一个”,结果 selected_index 变成 0,B 选项的涂写被忽略,题目被误判为错误。
原因:判分逻辑沿用了单选题里“找到第一个涂写选项”的做法,没有按集合比较两个选项。代码写得太顺,忘了多选题场景的存在。
解决:统一抽象成“选项索引集合”。不管是单选还是多选,都用集合的方式取出所有被涂写的选项,再和标准答案集合做比较。set 比较天然不受选项顺序影响,也绝不会出现“只取第一个”的问题。
5.3 透视变换后选项框偏移,特征区域和纸面选项对不上
现象:部分答题卡在透视校正后,前几道题判得很准,后面几道题错位明显,越靠下偏移越严重,连续多张卡在同一题号上翻车。
原因:答题卡本身不是完全标准的矩形,或者印刷时表格线有一点倾斜,均匀切分在顶部误差小、底部误差被放大。也可能是拍摄时答题卡边缘有一条纸边翘起,Canny 把翘起的边当成边框,导致透视变换把答题卡压变形。
解决:先跑一遍轮廓拟合的可视化,在图上画出拟合出的四条边,确认边框是否贴合实际纸边;再检查透视变换后的图像,用 50% 透明度叠加原来的答题卡,肉眼看是不是有明显畸变。如果只是底部偏移,可以按对位标记做分段校正。正规答题卡在四角或底边有黑色定位块,优先用定位块的中心坐标做透视变换,比用外轮廓更稳定。
5.4 反光和褶皱导致二值化后出现大块噪点
现象:手机拍摄的答题卡有明显的灯光反光带,反光区域里灰度值整体偏高,二值化之后形成大片杂乱的白色/黑色块,选项框边界被淹没,HOG 特征被严重干扰。
原因:这一步的问题不在 SVM,在预处理二值化。固定阈值 125 在正常光照下好用,遇到强反光就失效。整张图一个阈值太脆弱,光照不均匀的区域需要一个自适应阈值。
解决:预处理阶段不要用全局二值化,改用 adaptiveThreshold 做局部自适应阈值,blockSize 设 25 或 31,C 值设 5。这样局部反光区域会按它周围的亮度做二值化,能显著减少反光造成的噪声。但自适应阈值也会把一些中间色调的浅涂痕迹洗掉,所以它适合做像素占比统计的输入,不作为 HOG 特征提取的唯一依据。
5.5 训练样本太少,换一个品牌的答题卡识别率直线下降
现象:同一个模型,训练时用的是某教育机构的答题卡,测试时换成另一个品牌的答题卡,识别率从 96% 掉到 80%,多张卡需要人工复核。
原因:HOG 特征描述的是梯度分布,不同品牌的答题卡,印刷字体、选项框粗细、纸张底色都有差异。之前训练的样本只覆盖了一个品牌的风格,模型的决策边界完全是按照这个风格拟合的,到了陌生排版上就不接地气。
解决:做训练集时主动混入多个来源的答题卡,至少三个品牌或三个批次的印刷品。每批次只取一部分样本进训练集,剩下的做验证,确保模型在陌生排版上有一定的泛化。如果条件不允许,就做一个模型指纹机制,在第一张卡识别前先检测边框尺寸和选项框数量,和已有训练配置对不上就告警,提醒人工介入。
6. 把精度从 92% 提到 99%:一个现场验证的小技巧
HOG+SVM 在这个场景里能做到 95% 左右的准确率,但距离“放心自动判分”还有一点距离。我发现只要加一个二次验证,整体精度能稳定往上提三到五个百分点。做法是:SVM 预测完之后,不直接使用它的输出,而是把概率分数和像素统计结合,做一次交叉仲裁。
OpenCV 的 SVM 预测默认只输出类别标签,不直接给置信度。要拿到置信度,得换一种调用方式:用 predict 方法的决策值,或者用 SVM 的决策函数做软判决。更简单的做法是我们已经有的像素兜底逻辑——SVM 判 0 但像素占比高的,纠正为 1;相反,SVM 判 1 但像素占比特别低的,再人工看一次。这本质上不是让模型更聪明,而是让“误判”从静默状态变成“待人工复核”状态。
def robust_predict(svm, hog, roi, pixel_threshold=0.06): roi_resized = cv2.resize(roi, (64, 32)) feature = hog.compute(roi_resized).flatten().astype(np.float32) _, pred = svm.predict(feature.reshape(1, -1)) svm_result = int(pred[0][0]) # 二值化像素占比 gray = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY) _, binary = cv2.threshold(gray, 125, 255, cv2.THRESH_BINARY_INV) black_ratio = np.count_nonzero(binary) / binary.size if svm_result == 0 and black_ratio > pixel_threshold: return 1, "svm0-pixel1" # 疑似浅涂,按已涂处理 if svm_result == 1 and black_ratio < pixel_threshold * 0.2: return 0, "svm1-pixel0" # 疑似误判,按未涂处理 return svm_result, "consistent"二次验证的关键不是修出一个更完美的阈值,而是把仲裁结果记录到一个列表里,最后打印一份“疑似误判清单”,让阅卷老师按清单人工核对这几道题。自动判分系统最怕的是错得悄无声息,你永远不知道自己错在哪张卡、哪道题。有了这个清单,系统出错时你能精准定位,而不是盲目调参。
有一次现场批量跑 30 张卡,输出结果和使用人工分数对不上,排查了一个晚上才发现是一张卡折了个角,角落阴影让底部一个选项框的黑色像素比突然升高,像素仲裁把空白选项错判成涂写。后来我把像素阈值和纸张底色做了联动校准,先取答题卡外边框一圈的灰度均值作为基线,再动态调整阈值,这个问题才稳定下来。现在做任何识别项目,我都会先把“可视化中间步骤”和“待复核清单”当成和模型精度同等重要的模块写进去。希望这个思路也能帮到你批量阅卷时少踩几个坑。
本文还有配套的精品资源,点击获取