去年我帮一个模拟项目X做过一套答题卡识别并打分的小工具技术选型时几乎是毫不犹豫定了OpenCV。收到一堆手机实拍图之后我更确信这件事的核心不是“上多厉害的模型”而是把几何关系、二值化、轮廓和像素统计这些基础操作做扎实成绩单就能从含含糊糊的照片里稳定出来。这篇把完整的技术链路、参数、踩坑过程和工程落地经验一次讲清楚适合正在做课程设计、或者刚好要接类似扫描批改需求的开发者参考。很多初学者看到“答题卡识别”第一反应是上深度学习我实际做下来发现只要答题卡版式固定、拍照环境不是极端恶劣OpenCV这套经典视觉方案又快又稳而且每一步都是可解释、可调试的。后面我会按“预处理→透视校正→选项网格定位→填涂判定与打分→真实场景调优→工程落地”的顺序展开每一段都会说明为什么这么做、参数怎么调以及我踩过的具体坑。1. 为什么这个项目一定要先拆模块再写代码1.1 一张答题卡照片背后真正难处理的问题先看清楚输入长什么样我们手里的答题卡是A4纸打印的上面有题目区、选项圆、学号涂卡区域可能还有定位用的黑色矩形标记。照片是用手机从大约半米高处拍的角度不能保证完全垂直可能是稍微倾斜的梯形光线也是教室里那种不均匀的顶灯可能有一块阴影压在答题卡边缘。如果上来直接写“把选项圈出来”的代码大概率会卡在第一步。因为肉眼看到的答题卡在程序里只是一个1600×1200左右的彩色像素矩阵。真正难处理的有三件事。第一纸张边界和背景分不开。白纸放在白色桌面上时边缘像素差异极小传统的边缘检测可能根本拿不到完整边界。第二选项区域不是一个个完美圆形真实打印的圆可能被填涂得深浅不一甚至出现连笔把两个选项涂成一片。第三光线和阴影会直接改变二值化的结果同一张卡固定阈值在亮部能看清、在暗部就全黑。所以整个项目的正确打开方式是先按职责拆成独立模块每个模块只做一件事找卡片、扶正卡片、定位格子、判断填涂、计算得分。模块之间用中间图像串联起来方便随时定位哪一步出了问题。1.2 OpenCV在这里恰恰是最合适的工具箱很多人问“现在我连识别个A选项都要用深度学习吗”答案是不需要。作答卡识别本质上是一个高度结构化的几何问题版式是印好的题目固定选项固定填涂行为发生在固定位置的圆里。OpenCV提供的轮廓检测、透视变换、阈值分割、形态学操作正好覆盖了这些需求而且处理速度很快。一张普通手机照片从读入到出结果纯OpenCV流程大概在200到500毫秒批量处理时基本没有压力。深度学习方案的定位是“救火队员”而不是“主力”。比如极端失真有畸变、或者答题卡的印刷标记被人为遮挡无法通过几何规则定位时可以考虑用目标检测模型去帮我们把四个定位点找出来但它并不是日常批改的主流程。还有一个现实问题深度学习需要构造和标注大量答题卡样本不同版式的答题卡还得重新收集数据而OpenCV只需要改几行参数配置。在固定版式的场景里经典视觉的性价比是碾压级的。1.3 这套方案的适用边界哪些输入会让它变难受必须说清楚这套方案的边界否则你会拿着它去处理各种奇怪输入然后骂代码不稳定。OpenCV方案最舒服的输入是答题卡印刷版式固定、背景基本干净、手机镜头与纸面夹角不超过45度、光照没有大面积极端反差。在这些条件下识别准确率做到99%以上完全可行。如果拍照时纸面出现严重折痕、有大面积反光、或者答题卡本身就印满花哨背景那预处理难度会急剧上升有些情况确实需要人工介入。我在项目里给自己定了一条规律能靠物理设备解决的事情不丢给算法硬扛。如果现场条件允许尽量用固定的支架俯拍用夹子把答题卡压平。与其在后面写一堆对抗透视的代码不如从一开始就把拍摄角度稳定下来。后面所有模块的稳定性都建立在“输入别太离谱”的前提下。2. 扶正答题卡灰度增强、边缘轮廓与四点透视的实操细节2.1 预处理参数到底该怎么定拿到一张彩色原图很多人上来就是cv2.cvtColor转灰度然后直接cv2.threshold二值化。这个流程在扫描仪上没问题但在手机照片上很容易翻车。我这里的标准预处理链路是灰度→CLAHE增强→高斯模糊→自适应阈值或Canny。先做灰度化把三通道变成一通道降低计算量。然后用CLAHE做局部对比度增强。CLAHE可以理解成把图像按网格切成小块分别做直方图均衡避免整张图同一个对比度拉伸后亮部过曝、暗部仍然看不清。我常用参数是clipLimit2.0, tileGridSize(8,8)。接下来做高斯模糊核大小取5×5就够了。很多人问模糊会不会把选项边缘糊掉实际上高斯模糊的主要目的是抑制噪点如果图像本身比较清晰你可以把核降到3×3。重点是那个标准差参数OpenCV里不传时它会根据核大小自动算一般不要手动调太大。做完这些再往下游走就分两条路一条是Canny边缘检测用来找答题卡边界另一条是自适应阈值或Otsu二值化用来做选项区域分析。两条路的目的完全不同参数也得分开调。处理阶段推荐方法参数参考主要目的灰度化cv2.cvtColorCOLOR_BGR2GRAY降维减少计算量对比度增强CLAHEclipLimit2.0, tileGridSize(8,8)削弱光照不均影响降噪高斯模糊ksize(5,5)抑制边缘毛刺和噪点边缘检测Canny50, 150找到纸张与背景的分界线二值化Otsu或自适应均值THRESH_BINARY_INV把填涂区域变成白色前景2.2 用轮廓逼近定位纸张边界而不是傻傻找矩形定位答题卡纸张边界是整条流水线的第一个关键点。经典的思路是Canny检测边缘后找所有轮廓按面积从大到小排序然后对每个轮廓做多边形逼近只要逼近结果有四个顶点就认为它是答题卡的四边形轮廓。核心代码如下import cv2 import numpy as np def preprocess_for_edge(image): gray cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) clahe cv2.createCLAHE(clipLimit2.0, tileGridSize(8, 8)) gray clahe.apply(gray) blur cv2.GaussianBlur(gray, (5, 5), 0) edge cv2.Canny(blur, 50, 150) # 腐蚀膨胀很关键把断开的边缘连起来 kernel cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3)) edge cv2.dilate(edge, kernel, iterations1) return edge def detect_card_contour(edge): cnts, _ cv2.findContours(edge, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) cnts sorted(cnts, keycv2.contourArea, reverseTrue)[:5] card None for c in cnts: peri cv2.arcLength(c, True) approx cv2.approxPolyDP(c, 0.02 * peri, True) if len(approx) 4: card approx.reshape(4, 2).astype(float32) break return card这里有个容易踩的坑很多人直接用cv2.boundingRect找矩形但那是旋转矩形万一答题卡是歪的外接矩形会把大量背景也框进来。正确做法是用approxPolyDP做四边形逼近它能把轮廓近似成最少的多边形顶点当顶点数为4且面积足够大时就认为找到了纸张。为什么在Canny之后要加一次膨胀因为纸张边缘在自然光下可能断断续续膨胀能让断掉的线段重新连接成一个闭合轮廓。膨胀次数不要多一次就够两次以上容易把旁边物体粘连进来。还有一个细节sorted(cnts, keycv2.contourArea, reverseTrue)[:5]只取面积最大的前五个。如果背景里有一张纸的边缘比答题卡还大就可能误捡所以后面还要用宽高比做二次过滤。答题卡通常是标准的3:4或1:1.4左右过滤条件可以写0.7 w/h 1.5这类。2.3 透视校正与坐标统一后续所有步骤的基础得到四个角点后我们要把梯形图像“拉正”成矩形这一步用的是透视变换而不是仿射变换。仿射变换只能处理旋转、平移、缩放它默认变换前后是平行四边形但实拍的答题卡是梯形必须用单应矩阵处理。我习惯把四个角点按“左上、右上、右下、左下”排好序然后用cv2.getPerspectiveTransform计算变换矩阵再用cv2.warpPerspective输出正视图。def order_points(pts): rect np.zeros((4, 2), dtypefloat32) s pts.sum(axis1) rect[0] pts[np.argmin(s)] rect[2] pts[np.argmax(s)] diff np.diff(pts, axis1) rect[1] pts[np.argmin(diff)] rect[3] pts[np.argmax(diff)] return rect def warp_card(image, pts): rect order_points(pts) (tl, tr, br, bl) rect width 600 height 800 dst np.array([[0, 0], [width - 1, 0], [width - 1, height - 1], [0, height - 1]], dtypefloat32) M cv2.getPerspectiveTransform(rect, dst) warped cv2.warpPerspective(image, M, (width, height)) return warped这里的600×800不是随便拍的。它既要保留足够分辨率让选项圆清晰又不能太大导致后续处理变慢。以手机原图2000×1500为例映射到600×800后每个选项圆的直径一般还有25到35像素完全够用。如果分辨率再小比如300×400圆可能只剩十几个像素阈值稍微偏差就会误判。透视校正完成后所有后续处理都在这张正视图上进行。此时“哪个选项在哪个位置”变成了一组固定的坐标关系程序就不需要每次都在歪着的图上重新猜测稳定性直接上一个台阶。2.4 如果纸张边缘根本找不到就改用印刷标记有些实拍图里答题卡的边缘和背景桌面颜色太接近Canny后根本得不到闭合四边形。这时候就别死磕纸张边缘了更稳妥的做法是依靠答题卡上印好的定位标记。常见的印刷定位标记有几种四个角的黑色直角块、顶部的条形码区、或者边线上的黑色粗短横线。我们在自己用的模拟答题卡上四个角各印了一个实心黑色正方形。定位逻辑变成先二值化再用轮廓检测找到这些黑色方块每个方块中心作为固定坐标点。用印刷标记的好处是定位不受背景干扰因为黑色方块在整张图中足够独特。找到四个黑色方块中心后依然按左上、右上、右下、左下排序然后做透视校正。这样即使纸张边缘和背景完全融为一体只要答题卡本身可见就能稳定扶正。3. 选项网格定位投影聚簇、轮廓筛选与动态分列的三条路线3.1 二值化之后先把“题号行”找出来透视校正后我们拿到一张干净的正视图。接下来要做的是把每个题目对应的五个选项圆精确找出来。我的处理习惯是先用Otsu二值化把图变成黑白。cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV cv2.THRESH_OTSU)注意这里用了INV反转让填涂的黑色笔迹变成白色前景。Otsu会自动计算全图最优阈值比固定阈值要鲁棒得多。题目行的核心思路是“水平投影聚簇”。对二值图逐行统计白色像素数量画成一行直方图。选项圆会在其所在行的范围内形成明显的白色像素高峰题号数字和题干文字也会形成高峰。但不同区域高度不一样我们要先裁剪出纯选项区排除标题区和题干区减少干扰。平时我通过配置指定“只处理试卷中部的选项区域”比如roi warped[200:700, 50:550]。在这个ROI中每个题目行的峰都比较独立两题之间会有明显的低谷。把连续的高峰段合并成一个簇每个簇的中心就是这一题的y坐标然后沿竖直方向排序就得到了所有题号行的垂直位置。3.2 轮廓筛选能找到圆圈但别把它当唯一依据很多现成的OpenCV例程会直接对每个轮廓做外接矩形然后按宽高比和面积筛出选项圆。这种方法在扫描件上很漂亮因为扫描件里的圆个个整齐。但在手机照片里铅笔涂卡经常出现连笔、飞白甚至整片涂出圆外当你用findContours时两个相邻选项可能在同一个轮廓里连成一坨大块图形宽度和面积都远超正常圆反而被过滤掉。所以我的流程是“两条腿走路”先用轮廓筛选快速验证再把投影法当成主力。轮廓筛选主要负责早期调试时可视化确认而最终填涂判定的坐标网格来自投影法得到的行列划分。投影法不关心单个圆是否闭合它只统计“这一行有没有白色像素”即使两个圆被涂满连在一起行投影依然会形成一个峰列投影依然能看到两个相邻峰的间距只是峰之间的低谷变浅了。只要选项间距设计合理连体笔画不至于把五列完全融合成一个峰都能正常分离。3.3 x坐标聚类替代固定列切分适配铅笔涂出的连笔拿到每个题目行的y坐标后下一步就是对每一行做垂直投影把五个选项圆在x方向上的位置找出来。垂直投影同样统计白色像素数量得到的波形在x轴上会有五个峰对应A、B、C、D、E五个选项。理论很简单实际有一个关键点选项间距可能不是绝对均匀的尤其当答题卡被打印拉伸或者印刷偏差时固定宽度切分容易错位。所以我采用“x坐标聚类”而不是“固定列宽切分”。把每行中所有白色像素的x坐标收集起来用简单的一维聚类找到最大的五个簇中心。具体可以用cv2.findContours先拿到该行内所有白色连通域然后对每个连通域计算外接矩形的中心x坐标再把这些中心x坐标排序邻近的合并成一个簇。簇内点的x坐标均值就是该选项的x中心。4. 填涂状态判定与打分逻辑阈值、圆形掩膜与异常策略4.1 “涂没涂”的判断标准填充比值与圆形ROI定位好网格后真正给每个选项打分就简单了。我们不是去识别“A”还是“B”这个字母而是判断“这个圆里有没有被涂填”。把某个选项周围的矩形区域裁剪出来统计其中白色像素占整个区域面积的比例比例超过阈值就认为被涂了。但直接统计矩形区域有个陷阱矩形四角会扫到相邻选项的边甚至题号数字。为了只聚焦在选项圆内部我更推荐用圆形掩膜。以一个选项中心为圆心取该选项圆可见半径的70%做个中空或实心的圆形蒙版然后只统计蒙版区域内的白色像素占比。def circle_fill_ratio(binary_img, center, radius, inner_ratio0.75): h, w binary_img.shape[:2] mask np.zeros((h, w), dtypenp.uint8) cv2.circle(mask, center, int(radius * inner_ratio), 255, -1) total cv2.countNonZero(mask) region cv2.bitwise_and(binary_img, binary_img, maskmask) filled cv2.countNonZero(region) return filled / total if total 0 else 0.0阈值取多少我常用0.25到0.35之间。完全没涂的圆圆圈边框本身会给中心区域留下一些噪声但占比一般低于10%。涂得很浅的铅笔印占比也可能只有20%左右所以阈值低于0.25容易把没涂的判成涂了高于0.35又会漏掉浅涂卡。我自己最终固定在0.3然后用“可疑区间”做双保险比例在0.2到0.3之间的选项标为“弱涂”交人工复核。4.2 单选、多选与漏涂误涂的计分规则填涂判定完成后每个题目会得到一个或多个被涂选项。打分逻辑该单独写一个模块不要在图像处理代码里顺手算分。比如某道题的正确答案是“B”学生涂了“B”那么这题得分。如果学生涂了“B”又涂了“C”这属于单选中的多涂按异常处理而不是默认给分。对于多选题假设答案是“ACD”学生同时涂了“ACD”才算全对按严格规则只要缺少任何一个正确项或者多涂任何一个错误项就不得分。题型学生填涂正确答案判定结果单选题BB得分单选题B、CB异常多涂不得分多选题A、C、DA、C、D得分多选题A、CA、C、D不得分漏涂未涂任何选项任意不得分标黄提示实际代码里会先收集每个题目的选中集合再和标准答案字符串做比对。答案用集合存储这样顺序无所谓有利于处理多选题。4.3 学号信息和成绩汇总的处理思路答题卡上通常还有学号涂卡栏其实就是一组按数字列排列的小圆一行代表0到9十个数字。我们可以复用同一个网格检测代码先按行列投影找到学号栏的各个圆再对每个数字圆计算填充比例填充比例最高的数字就是这一位学号。如果学号区域是手写数字那OpenCV本身不擅长识别需要引入额外的OCR库或者只把区域截图存下来由人工核对。项目里当时为了控制复杂度学号用的就是涂卡方式所以纯OpenCV就搞定了。如果你需要处理手写学号建议先跑通客观题识别再单独加一层OCR管线不要把两件事混在一起调。成绩汇总建议直接输出CSV字段包含学号、姓名、每题对错、总分。命名时注意用学号作为主键同一学生多张答题卡可以按时间或答题卡类型分列避免覆盖。5. 真实场景踩坑光照不均、铅笔深浅、涂改残留与拍歪5.1 固定阈值翻车的三个例子我最早写第一版足有两天在跟固定阈值较劲。当时代码里写死cv2.threshold(gray, 127, 255, ...)结果一张室外窗边拍的答题卡左侧被阳光照得发白右侧被阴影压得发暗阈值127在亮区还能勉强看到圆在暗区几乎全黑整张卡的选项一个都认不出来。第二个翻车场景是答题卡纸本身偏黄。同样阈值127白纸和黄纸的灰度分布差一大截阈值没跟着变黄纸上的选项圆被判定成了背景。第三个翻车场景是铅笔颜色太浅。很多学生用HB铅笔答题笔迹灰度大约在180左右固定阈值127硬生生把浅笔迹当成了背景。换成Otsu自适应阈值后系统会根据整张图的灰度分布自动找分割点这三类问题的发作概率立刻小了很多。如果你的图中光照分布特别不均匀还可以用自适应阈值cv2.adaptiveThreshold它会基于邻域灰度动态算阈值处理阴影效果更好。5.2 铅笔浅涂和橡皮残留的对抗方法铅笔浅涂是最隐蔽的坑。学生在选项圆里轻轻扫了几笔视觉上能看到轮廓但二值化后白色像素占比只有25%左右差点低于阈值被判成漏涂。我的应对手段有三招。第一招二值化前对ROI做一次局部对比度增强让铅笔印和纸张背景的差距拉大。第二招判定阈值不要定死用Otsu算出的实际阈值做一个归一化再和固定阈值结合。第三招对二值图做一次闭运算闭运算可以填充铅笔印内部的细小空白把浅涂区域连成片。橡皮残留则是另一个极端。学生擦得不够干净残留的轻度石墨颗粒会让原本空白的圆圈里出现零散的白色噪点占比直接超过阈值。这里圆形掩膜的裁剪范围不是全圆而是圆中心区域的70%能把绝大部分边框残留和边缘脏污排除在外。如果还误判我会把判定条件从“平均占比超过阈值”改成“中心区域最大连通域面积超过阈值”这是更严格的一种策略。5.3 倾斜、透视和反光手机实拍逃不开的物理限制手机实拍最大问题是三维倾斜。有些同学拍照时手一抖纸面会变成很夸张的梯形甚至平行四边形透视校正能把平面旋转纠正过来但纠正不了景深方向上因为纸面弯曲造成的畸变。纸面越平校正后各选项的位置误差越小纸面拱起来时校正图靠近中间和边缘的宽度会失真选项坐标也跟着偏移。反光问题也常见尤其答题卡表面覆了哑膜之后。反光区域会在图像中形成一片高亮白色严重时二值化后整片区域都是白色前景把选项圆淹没。这时候单纯调阈值没用最好在拍照时用偏振膜或换个光源方向如果没有条件只能把那道题标记为异常让审核人员复核。姿态问题我建议从流程上规避把答题卡放进一个带固定卡位的硬底板用40到50厘米高的支架俯拍。物理固定之后透视变换矩阵的变化范围非常小稳定性大幅提升。这个经验后来帮我们减少了大量无效调试时间。5.4 一个只改一个变量、死磕中间图的调优套路许多人调参失败是因为同时改了五六个变量一旦效果变差根本不知道是哪个环节导致的。我后期的调优方法是“一次只改一个变量每改一次就保存所有中间结果”。比如怀疑阈值过高导致漏涂那就只调阈值从0.3降到0.25然后重新跑全部测试图对比每个选项圆形掩膜的填充比值看分布是不是明显分成了两个簇。如果还是重叠再调下一个变量比如闭运算的核大小。每一个中间步骤的图像都要能单独保存原图、灰度图、边缘图、校正图、二值图、网格图、填涂标记图。这样排查错误的时候直接看哪一步的中间输出不对就知道该改哪里。这个习惯帮我少走了很多弯路。6. 从Demo到稳定落地可视化调试、参数配置与最小回归集6.1 每一步都留图的调试习惯我在项目里规定识别程序必须有一个debug_dir参数打开后会把所有中间步骤输出成图片。这个习惯一开始看起来浪费时间等到真出现问题就像有了一整条线索链。举个例子有一次某张卡的红笔写的姓名被当成了答题区前景导致行投影出现一个诡异的峰。如果没有中间图只能对着结果瞎猜。有了每一阶段的图一眼就能看到是“姓名笔迹在二值化后进入了选项区域”。解法也很简单在透视校正后先用ROI把姓名区域裁掉只对选项区计算投影。6.2 把答题卡参数写进配置文件换版式不动代码不同学校的答题卡版式差异很大有的题目20题有的30题有的每行5个选项有的6个有的学号区在右上角有的在左侧。如果这些信息全部硬编码换一张版式就要改代码非常痛苦。我把版式参数抽成了配置用一个YAML或者JSON文件描述config { card: { width: 600, height: 800, option_rows: 20, options_per_row: 5, option_radius: 14, region: [180, 620, 30, 570], fill_threshold: 0.3, weak_threshold: 0.2 } }识别代码启动时读取配置所有的ROI、网格、半径都从配置计算而不是从常量计算。这样即使换了一种答题卡也只需要按照新版式改配置文件图像处理核心代码一行不动。用这种方法我们后来接第二第三种答题卡版式时只花了一个下午。6.3 批量批改的异常队列与质量评估批量处理时除了成绩还必须输出一个“异常卡队列”。只要出现以下情况之一这张卡就标记成异常多涂选项数量异常、检测不到定位标记、透视校正失败、得分低于某个明显可疑的下限。异常卡不要直接判0分而是单独存到文件夹供人工人工复核。质量评估用测试集说话。收集至少20到30张真实手机拍摄的答题卡覆盖正常、偏亮、偏暗、倾斜、有阴影、浅涂、橡皮残留等场景。每张卡人工标注标准答案和填涂情况跑一遍完整流程统计正确率、漏检率、误检率。以后每调整一次参数都跑一遍这个最小回归集。只要回归集的准确率不下降参数改动就可以接受。这里我特别想提一个工程态度识别程序不该追求“所有卡都能自动判”而是要诚实地告诉使用者哪些卡我有把握、哪些卡我不敢判。宁可把可疑卡挑出来给人看也不能让系统把错误答案当成正确答案送进成绩单。这是我在多次实际批改项目里最深的体会。答题卡识别看起来是一个视觉问题做到后面更像一个可靠性工程问题。先把输入管好把流程拆开把每个阶段可视化再用回归集守住准确率这个项目就真正落地了。