ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

机器视觉试卷分数智能识别:OCR与轮廓识别实战

机器视觉试卷分数智能识别:OCR与轮廓识别实战 简介这份PDF文献面向教育技术研究者、机器视觉方向的学生与工程开发人员针对学校试卷合分环节人工统计速度慢、易出错、Excel录入繁琐等痛点给出了一套基于机器视觉的试卷分数智能识别系统完整设计方案。资源包共1个PDF文件约1.26MB内容为期刊论文全文含系统架构图、硬件选型表与试验对比数据。文中详细展开图像采集、预处理、分数轮廓边缘提取、文字OCR识别与分数统计分析的完整流程并给出工业相机MER-310-12UC、Schneider工业镜头、OPT-RI909环形光源等具体选型参数以及基于VS2010与OpenCV的软件架构与界面设计思路。读者可据此理解机器视觉识别系统的软硬件协同方法获取轮廓提取算法与OCR算子结合的实践参考并借鉴其试验验证与效率对比思路用于课程设计、论文写作或小型检测系统开发。目前已有137人学习。1. 从一张手写分数到结构化成绩机器视觉试卷分数识别到底在做什么期末考试结束教务老师抱来一摞答题卡每张卡上除了客观题涂卡区还有一道大题旁边用红笔手写的分数。要把这些分数录入 Excel传统做法是两个人对着念、一个人敲键盘一天下来眼睛发花还容易串行。机器视觉试卷分数智能识别系统要解决的就是把这个「人眼找分数、人脑认数字、人手敲表格」的流程换成摄像头加算法自动完成。它属于 OCR 文字识别的一个垂直分支但和扫描整页文档不同试卷分数识别的难点在于分数位置不固定、笔迹颜色和纸张对比度差异大、数字可能被红笔勾画遮挡、一张图里同时存在印刷体和手写体。适合做这件事的人是已经会用 OpenCV 做基础图像处理、想找一个完整项目把轮廓识别、字符分割、OCR 识别串起来的工程师或者教务信息化团队里需要快速落地一个离线识别工具的开发人员。热搜词里「机器视觉」「试卷分数」「智能识别系统」「OCR」「轮廓识别」这几个词恰好对应了这条流水线的五个环节成像、定位、分割、识别、输出。下面我按自己实际搭过的一套方案把每个环节的参数和坑讲清楚。2. 成像与预处理让分数区域从试卷背景里「浮」出来2.1 为什么直接上 OCR 会翻车先解决光照和透视很多人拿到试卷照片第一反应是丢给 Tesseract 或 PaddleOCR 直接识别结果识别率惨不忍睹。原因不是 OCR 引擎不行而是输入图像的质量根本没达到 OCR 的舒适区。试卷分数识别对图像有三个硬要求分数区域与背景的对比度足够高、纸张不能有严重透视变形、红笔笔迹不能因为曝光过度变成白色。我一般会在预处理阶段做三件事灰度化、自适应直方图均衡、透视校正。灰度化不是简单取平均值而是用加权公式让红色通道保留更多权重因为分数通常是红笔写的。OpenCV 的cvtColor默认权重对红色不友好我会手动做通道分离后加权。import cv2 import numpy as np def preprocess_exam_image(img_path): # 读取原图保留彩色通道用于后续红色提取 img cv2.imread(img_path) if img is None: raise FileNotFoundError(f无法读取图像: {img_path}) # 分离 BGR 三通道红色笔迹在 R 通道最明显 b, g, r cv2.split(img) # 加权灰度化提高红色通道权重让红笔分数更突出 gray cv2.addWeighted(r, 0.6, g, 0.3, 0) cv2.addWeighted(b, 0.1, np.zeros_like(b), 0, 0) gray np.clip(gray, 0, 255).astype(np.uint8) # CLAHE 限制对比度自适应直方图均衡避免全局过曝 clahe cv2.createCLAHE(clipLimit2.0, tileGridSize(8, 8)) enhanced clahe.apply(gray) # 高斯模糊去噪核大小根据分辨率调整一般 3x3 或 5x5 blurred cv2.GaussianBlur(enhanced, (3, 3), 0) return img, blurred这段代码的逻辑是先把红色通道权重拉高让红笔分数在灰度图里比印刷体更亮然后用 CLAHE 做局部对比度增强避免整张图因为灯光不均导致一边过曝一边欠曝最后用 3x3 高斯核去噪。参数上clipLimit2.0是经验值设太大噪声会被放大设太小增强效果不明显tileGridSize(8,8)适合 2000x3000 左右的试卷照片如果图像更大要相应调大。透视校正需要先找到试卷的四个角点通常用最大轮廓逼近四边形再用getPerspectiveTransform做映射。这一步如果跳过倾斜拍摄的试卷会让分数区域变成梯形后续轮廓识别会误判。2.2 红色笔迹分离HSV 阈值比 RGB 减法更稳灰度化之后分数区域和印刷体混在一起直接做轮廓识别会找到一堆无关的边框和表格线。我的做法是先做颜色分离把红色笔迹单独提取出来。RGB 空间做红色分离很容易受光照影响换成 HSV 空间后色调 H 对光照变化不敏感。红色在 HSV 里对应两个区间H 在 0 到 10 和 170 到 180 之间。我一般用inRange做双区间掩膜再合并。def extract_red_marks(img): # 转换到 HSV 空间 hsv cv2.cvtColor(img, cv2.COLOR_BGR2HSV) # 红色在 HSV 中跨越 0 和 180需要两个区间 lower_red1 np.array([0, 50, 50]) upper_red1 np.array([10, 255, 255]) lower_red2 np.array([170, 50, 50]) upper_red2 np.array([180, 255, 255]) mask1 cv2.inRange(hsv, lower_red1, upper_red1) mask2 cv2.inRange(hsv, lower_red2, upper_red2) red_mask cv2.bitwise_or(mask1, mask2) # 形态学闭运算连接断裂的笔画 kernel cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)) red_mask cv2.morphologyEx(red_mask, cv2.MORPH_CLOSE, kernel, iterations2) # 与灰度图做掩膜只保留红色区域 red_only cv2.bitwise_and(img, img, maskred_mask) return red_mask, red_only这里lower_red1的 S 和 V 下限设 50 是为了过滤掉浅粉色和暗红色噪声如果试卷纸张偏黄S 下限可以降到 30。闭运算的核用椭圆比矩形更贴合笔画形状迭代 2 次能连接断开的数字笔画但迭代次数太多会让相邻数字粘连。这一步的输出是一张二值掩膜白色区域就是红笔分数候选区。常见翻车点是红色印章和红色批改符号也被提取出来解决办法是在轮廓筛选阶段用面积和长宽比过滤。3. 轮廓识别与分数区域定位从一堆红色斑块里找到真正的分数3.1 轮廓筛选的四个几何参数面积、长宽比、实心度、位置红色掩膜里通常包含分数、批改勾、日期、签名等多种红色元素。要定位分数我一般用findContours提取所有外轮廓然后按四个参数过滤。面积过滤掉太小的噪点和太大的整页红框长宽比过滤掉细长的勾和横线实心度轮廓面积与外接矩形面积之比过滤掉空心框位置过滤掉页眉页脚区域。分数数字通常是 1 到 3 个字符外接矩形长宽比在 0.3 到 3 之间面积在 200 到 5000 像素之间取决于分辨率。def locate_score_regions(red_mask, img_shape): contours, _ cv2.findContours(red_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) candidates [] h_img, w_img img_shape[:2] for cnt in contours: area cv2.contourArea(cnt) # 面积过滤太小是噪点太大是整页红框 if area 200 or area 5000: continue x, y, w, h cv2.boundingRect(cnt) aspect_ratio w / float(h) if h 0 else 0 # 长宽比过滤分数数字不会太扁也不会太瘦 if aspect_ratio 0.2 or aspect_ratio 5.0: continue # 实心度过滤排除空心矩形和边框 rect_area w * h extent area / float(rect_area) if rect_area 0 else 0 if extent 0.3: continue # 位置过滤排除页眉页脚分数一般在页面中下部 if y h_img * 0.1 or y h_img * 0.95: continue candidates.append((x, y, w, h, area)) # 按 y 坐标从上到下排序方便后续按题号顺序输出 candidates.sort(keylambda c: c[1]) return candidates参数说明area下限 200 是针对 300 万像素以上的照片如果图像分辨率低要按比例调小aspect_ratio上限 5.0 是为了排除长横线下限 0.2 排除竖线extent下限 0.3 能过滤掉大部分空心批改框。位置过滤里y h_img * 0.95排除页脚但有些试卷分数写在右下角这个阈值要按实际版式调整。我一般会先把所有候选框画出来看一眼确认没有漏掉分数再进入下一步。3.2 字符分割投影法切分粘连数字定位到分数区域后如果区域里是「95」这样的两位数还需要切成单个字符再送 OCR。直接整块送 OCR 对两位数的识别率还行但遇到「100」这种三位数或者手写连笔就容易错。我一般用垂直投影法做字符分割对二值化后的分数区域按列求和找到投影值接近零的列作为切分点。def split_characters(region_gray): # 自适应二值化应对光照不均 binary cv2.adaptiveThreshold(region_gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 11, 2) # 垂直投影按列统计白色像素数 col_sum np.sum(binary, axis0) / 255 threshold max(1, region_gray.shape[0] * 0.05) # 至少 5% 高度有像素才算字符列 # 找连续非零列段 segments [] in_char False start 0 for i, val in enumerate(col_sum): if val threshold and not in_char: in_char True start i elif val threshold and in_char: in_char False if i - start 2: # 忽略太窄的段 segments.append((start, i)) if in_char: segments.append((start, len(col_sum))) # 按段切分字符 chars [] for s, e in segments: char_img binary[:, s:e] # 统一缩放到 28x28方便后续识别模型输入 char_resized cv2.resize(char_img, (28, 28), interpolationcv2.INTER_AREA) chars.append(char_resized) return charsadaptiveThreshold的blockSize11和C2是常用组合blockSize 要大于字符笔画宽度C 控制二值化阈值偏移。投影阈值设 5% 高度是为了过滤掉上下边框的残留像素。切分后的字符缩放到 28x28 是为了适配轻量级 CNN 分类器如果直接用 Tesseract 识别可以跳过缩放。常见问题是手写数字「4」和「1」的竖笔在投影上可能断开导致一个字符被切成两段解决办法是合并间隔小于 3 像素的相邻段。4. 分数识别Tesseract 和轻量 CNN 怎么选、怎么调4.1 Tesseract 的配置陷阱psm 和 whitelist 必须改Tesseract 是离线 OCR 里最容易上手的工具但默认配置对试卷分数识别极不友好。默认页面分割模式 psm 是 3全自动它会试图把整张图当文档处理对单个数字区域反而容易识别成乱码。分数识别应该用 psm 10单字符或 psm 7单行。另外必须设置字符白名单只允许 0-9 和可能的「.」否则会把「5」识别成「S」「0」识别成「O」。import pytesseract from PIL import Image def recognize_with_tesseract(char_img): # char_img 是 OpenCV 格式的 numpy 数组先转 PIL pil_img Image.fromarray(char_img) # 配置psm 10 单字符模式白名单只允许数字和小数点 custom_config r--oem 3 --psm 10 -c tessedit_char_whitelist0123456789. try: text pytesseract.image_to_string(pil_img, configcustom_config) # 清理空白和换行 result text.strip().replace(\n, ).replace( , ) return result if result else None except Exception as e: print(fTesseract 识别失败: {e}) return None--oem 3表示使用默认 LSTM 引擎--psm 10是单字符模式。如果分数区域没有切分整块送进去psm 要改成 7。白名单里加点号是为了处理「95.5」这种带小数的分数。Tesseract 对手写数字的识别率取决于训练数据英文版默认模型对手写支持一般如果试卷分数以手写为主建议用 jTessBoxEditor 自己训练一个数字字库或者直接换 CNN 方案。常见翻车点是 Tesseract 把「1」识别成空字符串原因是字符太细二值化后像素太少解决办法是在切分后做一次膨胀操作把笔画加粗。4.2 轻量 CNN 分类器自己训练一个 10 类数字模型如果手写分数占多数Tesseract 的识别率可能只有 70% 左右这时候自己训练一个 10 类 CNN 更靠谱。网络结构不用复杂两个卷积层加两个全连接层就够了输入 28x28 灰度图输出 0-9 的 softmax。训练数据可以用 MNIST 加上自己标注的试卷分数样本MNIST 提供 6 万张手写数字但风格和试卷红笔笔迹有差异我一般会额外标注 200 到 500 张实际试卷分数图做微调。import torch import torch.nn as nn import torch.nn.functional as F class DigitCNN(nn.Module): def __init__(self): super(DigitCNN, self).__init__() # 第一个卷积层1 通道输入16 个 3x3 卷积核 self.conv1 nn.Conv2d(1, 16, kernel_size3, padding1) self.conv2 nn.Conv2d(16, 32, kernel_size3, padding1) self.pool nn.MaxPool2d(2, 2) # 28x28 经过两次池化变成 7x732 通道展平后 32*7*71568 self.fc1 nn.Linear(32 * 7 * 7, 128) self.fc2 nn.Linear(128, 10) self.dropout nn.Dropout(0.3) def forward(self, x): x self.pool(F.relu(self.conv1(x))) # 28x28 - 14x14 x self.pool(F.relu(self.conv2(x))) # 14x14 - 7x7 x x.view(-1, 32 * 7 * 7) x F.relu(self.fc1(x)) x self.dropout(x) x self.fc2(x) return x # 训练时的关键参数 # 优化器Adam学习率 1e-3 # 损失函数CrossEntropyLoss # batch_size64 # epoch10 到 20根据验证集准确率早停这个网络在 MNIST 上能到 99% 以上在试卷分数微调后通常能到 95% 左右。训练时注意把输入归一化到 0-1 之间并且做随机旋转-10 到 10 度和随机缩放0.9 到 1.1 倍增强模拟拍摄时的角度和距离变化。推理时对每个切分后的字符图做同样预处理取 softmax 最大值的类别作为识别结果。如果分数区域没有切分可以用 CTC 或者注意力机制做序列识别但实现复杂度高很多我一般优先保证切分准确。5. 避坑与排查试卷分数识别里最容易翻车的五件事5.1 现象红色分数被识别成空白掩膜里什么都没有原因通常是 HSV 阈值范围不对。不同品牌的红笔色调差异很大有的偏橙H 接近 10有的偏紫H 接近 170。如果只设了 0-10 区间偏紫的红笔就漏掉了。解决方法是先用cv2.cvtColor把试卷图转 HSV用cv2.imshow或保存中间图看红色区域的 H 值分布再调整lower_red和upper_red的上下限。我一般会把两个区间都设宽一点比如 0-15 和 160-180然后用形态学操作去掉噪声。5.2 现象轮廓识别把表格线当成分数区域试卷上有很多黑色表格线如果预处理时没有做颜色分离直接对灰度图做轮廓识别表格线会形成大量细长轮廓。即使做了红色分离如果红色掩膜里混入了红色表格线有些试卷用红色印表格也会误检。解决办法是在轮廓筛选里加长宽比上限表格线通常长宽比大于 10而分数数字小于 5。另外可以用cv2.minAreaRect计算轮廓的最小外接矩形如果矩形角度接近 0 或 90 度且长宽比很大直接排除。5.3 现象Tesseract 把「5」识别成「S」「0」识别成「O」这是白名单没设导致的。Tesseract 默认字符集包含字母手写数字的笔画特征和某些字母很像。必须在 config 里加tessedit_char_whitelist0123456789.。如果设了白名单还出错检查 psm 是否设成了 10 或 7psm 3 会做版面分析可能把单个数字当成单词的一部分。另外图像分辨率太低也会导致误识别字符高度至少要有 20 像素低于这个值先做放大。5.4 现象两个数字粘连切分后识别成乱码手写分数「95」的「9」和「5」可能连在一起垂直投影找不到零值列。解决办法有两个一是用cv2.erode做一次腐蚀把粘连处变细再投影二是用连通域分析如果两个字符粘连连通域会是一个这时候可以用分水岭算法或者基于笔画宽度的切分。我一般先用腐蚀试腐蚀核大小从 1x1 开始试太大能把笔画腐蚀断。如果腐蚀解决不了就上投影法找局部最小值点作为切分点而不是找零值点。5.5 现象同一张试卷多次识别结果不一致这是最让人头疼的玄学问题通常和图像预处理的不确定性有关。CLAHE 的 tileGridSize 如果和图像尺寸不匹配每次增强结果会有微小差异自适应二值化的 blockSize 如果设成偶数OpenCV 会报错或行为异常。解决办法是把所有预处理参数固定下来并且在做二值化之前先把图像缩放到统一尺寸比如宽度固定 2000 像素。另外 Tesseract 的 LSTM 引擎在某些图像上会有随机性如果要求完全可复现建议用 CNN 分类器替代。6. 从单张识别到批量流水线一个可复用的工程化技巧单张试卷识别跑通后真正要落地还得解决批量处理的问题。我一般会把整个流程封装成一个类输入是一个文件夹路径输出是一个 CSV 文件每行包含文件名、题号、识别分数。这里的关键技巧是「两级缓存」第一级缓存预处理后的红色掩膜第二级缓存切分后的字符图。这样如果 OCR 识别环节需要调参重跑不用重新做图像预处理速度能快 5 到 10 倍。import os import csv import cv2 import numpy as np from concurrent.futures import ThreadPoolExecutor class ExamScorePipeline: def __init__(self, input_dir, output_csv, num_workers4): self.input_dir input_dir self.output_csv output_csv self.num_workers num_workers self.cache {} # 文件名 - 预处理结果 def process_single(self, filename): img_path os.path.join(self.input_dir, filename) img, gray preprocess_exam_image(img_path) red_mask, _ extract_red_marks(img) regions locate_score_regions(red_mask, img.shape) results [] for idx, (x, y, w, h, area) in enumerate(regions): region_gray gray[y:yh, x:xw] chars split_characters(region_gray) score_str for char_img in chars: digit recognize_with_tesseract(char_img) if digit: score_str digit if score_str: results.append((filename, idx 1, score_str)) return results def run(self): files [f for f in os.listdir(self.input_dir) if f.lower().endswith((.jpg, .png, .jpeg))] all_results [] with ThreadPoolExecutor(max_workersself.num_workers) as executor: futures [executor.submit(self.process_single, f) for f in files] for future in futures: all_results.extend(future.result()) # 写入 CSV按文件名和题号排序 all_results.sort(keylambda r: (r[0], r[1])) with open(self.output_csv, w, newline, encodingutf-8) as f: writer csv.writer(f) writer.writerow([文件名, 题号, 识别分数]) writer.writerows(all_results) return len(all_results) # 使用示例 # pipeline ExamScorePipeline(./exam_images, ./scores.csv, num_workers4) # count pipeline.run() # print(f共识别 {count} 个分数)这个流水线的核心设计是线程池加结果排序。ThreadPoolExecutor的num_workers设成 CPU 核心数就行IO 密集型的图像读取可以多开几个但 OCR 识别是 CPU 密集型开太多反而会争抢资源。CSV 输出按文件名和题号排序方便教务老师直接导入 Excel。如果试卷数量超过 1000 张建议把中间结果存成 JSON 或 SQLite避免内存里堆积太多数据。一个我踩过的坑是不同试卷的拍摄分辨率不一致有的 1200 万像素有的 800 万像素导致轮廓筛选的面积阈值失效。后来我在预处理里加了一步统一缩放把图像长边缩放到 2500 像素短边按比例缩放这样面积阈值就稳定了。另一个坑是红色掩膜的闭运算迭代次数迭代 2 次对大多数试卷够用但遇到笔画特别细的红笔迭代 3 次才能连上迭代次数多了又会让相邻数字粘连。我的习惯是先把所有试卷的红色掩膜跑一遍统计轮廓数量和面积分布再定迭代次数和面积阈值。这套方案在 500 张真实试卷上跑下来印刷体分数识别率 98% 以上手写分数识别率 92% 左右比人工录入快了一个数量级。如果你也在做类似的项目建议先把预处理和轮廓定位调稳OCR 环节反而不是最难的。希望帮到你。本文还有配套的精品资源点击获取
返回列表