
做OpenCV实战项目图像拼接和答题卡识别是我最推荐新手走的两条路线。图像拼接这一条从特征提取、特征匹配、单应性矩阵到图像融合基本把OpenCV最经典的视觉几何链路全部走了一遍答题卡识别这一条从图像预处理、透视矫正、二值化到轮廓分析和逻辑判分又是另一套完全不同的经典组合。两个项目放在一起练完我敢说你不可能再只会调用那几个基础API而是真正理解“一个完整的CV任务是怎么串起来的”。适合谁看两类人一类是自己跟课程学完OpenCV基础API但还没独立做过完整项目想找一个不算太难、结果又很直观的练手方向另一类是课程设计或者毕业设计准备选“基于OpenCV的XX系统”这类题目想提前把技术路线摸清楚。如果你连读取图片这类最基本的操作都还没跑通建议先去跑官方入门示例再回来看这篇不然代码里的很多参数会不知道为什么要调。1. 项目整体设计与技术选型1.1 两个项目为什么值得一起学图像拼接和答题卡识别放在一起学不是随意拼凑的。从技术栈来看两者的核心链路高度重叠都要做图像预处理都要依赖特征或轮廓信息做几何分析最后都要用OpenCV的几何变换和逻辑判断输出结果。区别在于——图像拼接是把多张小图合成一张大图考验的是“怎么对齐”答题卡识别是从一张带透视形变的图中还原盘面考验的是“怎么矫直”。一个练特征匹配一个练几何矫正正好互补。另一个原因是这两个项目都是“完成型”项目有明确的交付物。写完图像拼接你能直接拿手机拍两张有重叠区域的照片拼成一张全景图写完答题卡识别打印一张答题卡、涂几格就能跑出分数。这种正反馈对初学者来说特别重要不然学着学着就在某个参数里迷失了。从难易梯度上看图像拼接略抽象一点因为特征匹配和单应性矩阵背后有不少数学概念但代码量其实不多答题卡识别代码量稍大但每一步的目的都很直观——找答题卡、转正、找涂卡的位置、读答案。先做图像拼接热身再做答题卡识别节奏刚好。1.2 核心技术栈与工具版本我用的是Python 3.9 OpenCV 4.x这里要提醒一个版本相关的问题OpenCV 4.x里SIFT虽然能用但前提是安装opencv-contrib-python因为SIFT和SURF都在contrib模块里。不过我后面的代码默认用ORB原因有两个一是ORB完全免费没有专利限制二是ORB在实时场景下速度优势明显答题卡这种边缘清晰、涂卡区域规整的场景ORB完全够用。如果你追求更高的匹配精度可以换成SIFT代码里只需要改特征点提取那一行。但注意匹配策略也要跟着调整SIFT描述子是浮点型的匹配方式和ORB的二值描述子不一样不要混用。这个细节我见过好几个人踩坑——ORB提取特征用BFMatcher(NORM_HAMMING)SIFT要用BFMatcher(NORM_L2)搞混了会报错或者匹配效果莫名其妙地差。另外如果输入图片分辨率很高建议先统一缩放到一个合理尺寸再处理。我习惯把长边限制在1200像素左右后面所有处理的耗时都会稳定很多。图像拼接如果输入图是几千像素的大图单应性矩阵求解过程中特征点匹配会非常慢而且容易出现抖动。答题卡识别同样建议先缩小除非你识别的是极低分辨率的图。1.3 整体流程拆解与方案取舍先看图像拼接的主流程读取重叠图片提取特征点匹配特征点用RANSAC筛选正确匹配并计算单应性矩阵根据矩阵把其中一张图透视变换到另一张图的坐标系最后在重叠区域做融合处理。再看答题卡识别的主流程读取图像灰度化高斯模糊边缘检测找到答题卡轮廓通过轮廓的四角做透视矫正二值化后定位每道题的涂卡区域统计每个区域的黑像素占比按预设标准答案对比判分。两条流程放一起能看到一个共同点它们都离不开“找对应关系”这一步。图像拼接找的是两幅图之间的像素对应关系答题卡识别找的是图像坐标到逻辑坐标题号选项的对应关系。理解了这一点你后面看代码就不会晕因为核心都在算坐标系。方案取舍上拼接部分我放弃了直接调用cv2.Stitcher_create()因为官方封装太黑盒出了问题很难排查。自己写流程虽然代码多了几行但每一步的中间结果都能看见排查起来方便得多。答题卡部分我也没有用现成的OCR库因为答题卡识别本质上是一个规则明确的标记检测问题不需要OCR那么复杂的模型反而用经典图像处理手段更快、更可控。2. 图像拼接从特征匹配到全景融合2.1 特征点提取与匹配策略特征点的选择直接决定拼接能不能成功。简单说特征点就是图像里那些有“辨识度”的角点和纹理点——比如建筑拐角、窗户边角、文字边缘的交叉点。OpenCV里最常用的是ORB和SIFT。ORB全称Oriented FAST and Rotated BRIEF本质是FAST角点检测BRIEF描述子加了旋转不变性SIFT则是尺度空间极值检测对缩放和旋转都有较强的不变性。实操中拼接的两张图往往来自同一台相机、拍摄角度接近不会出现大幅度旋转或尺度变化ORB已经能稳定匹配。但在特征点提取阶段有一个玄学参数特征点总数。OpenCV的ORB.create()默认nfeatures500对普通场景够用但对拼接这种需要大量匹配支撑的场景我通常调大到1000到2000保证即使有误匹配RANSAC筛完之后还剩下足够的正确点。这个参数不是越大越好太大的话特征点分布会非常密匹配阶段耗时成倍增加而且容易出现局部特征点扎堆的情况。匹配阶段的坑比提取阶段多。BFMatcher是暴力匹配把所有特征点两两计算距离FLANN则用近似最近邻适合大特征量的场景。对于ORB距离度量要选NORM_HAMMING。匹配完之后单纯取最近邻经常出现“一对多”的混乱匹配——也就是图1的一个点匹配到图2的好几个点。我一般用两个策略收紧第一个是交叉匹配即互相验证——图1的A匹配图2的B且图2的B匹配图1的A两个方向都成立才保留第二个是比率测试也就是Lowe提出的方法比较最近距离和次近距离的比值比值小于阈值通常0.75才保留。两个策略叠加后匹配数量会减少很多但质量大幅提升。这里有一个容易忽略的点比率测试只对SIFT这类浮点描述子效果好因为它们的距离分布有区分度ORB是二值描述子距离是汉明距离数值离散、跳跃大比率测试的效果不如交叉匹配那么稳定。所以我的实践经验是——ORB优先用交叉匹配SIFT优先用比率测试。如果你两个都用ORB硬套Lowe比率测试经常会出现匹配点过少、单应性矩阵算不出来的问题。2.2 单应性矩阵与RANSAC原理得到若干匹配点后下一步是计算单应性矩阵H。单应性矩阵描述两张图像之间的平面投影变换包含8个自由度最后一个元素通常归一化为1。对于拼接场景假设拍摄的是同一个平面场景比如远处的风景、一面墙或者相机绕光心旋转那么两张图之间的变换就可以用单应性矩阵表示。公式是x H x也就是把图1的坐标变换到图2的坐标系里。用生活化的类比说单应性矩阵就是一张“坐标翻译表”它告诉你第一张图里的每个点在第二张图里应该站到哪个位置。求解H至少需要4组匹配点。但问题是匹配结果里总会有误匹配——这就是RANSAC存在的意义。RANSAC的思路很朴素随机抽4组点算出H看这个H能让多少组匹配点满足误差阈值保留满足最多的那组H然后用所有满足的点重新优化H。这种随机采样加投票的机制能扛住相当比例的误匹配这是直接最小二乘做不到的。OpenCV的cv2.findHomography(srcPoints, dstPoints, cv2.RANSAC, 5.0)封装了整套流程。最后一个参数5.0是重投影误差阈值单位是像素意思是预测点和实际点的距离在5像素内才算这个匹配是“内点”。这个阈值很关键设大了误匹配容易被当成内点H算得粗糙设小了正确匹配也被剔除H算不出来。我用5.0做默认如果匹配点数少于20把阈值放宽到8.0如果匹配点数很多但拼接结果有明显错位就收紧到3.0甚至2.0。这个参数的敏感性仅次于特征匹配质量调的时候要有耐心一次只调一个变量。我见过很多人直接拿所有匹配点用最小二乘去拟合H结果最后拼接图完全错乱。原因很简单——哪怕只有一个误匹配点最小二乘会把它的误差平均分摊到所有点上导致整体轻微扭动两张图的重叠区域出现重影。所以RANSAC这一步绝不能省。2.3 图像变换与融合算法单应性矩阵算出来后剩下的工作就是变换和融合。cv2.warpPerspective把其中一张图按H变换到另一张图的坐标系得到的结果和待拼接的图叠加在一起就形成了拼接图的雏形。但这时候还有一个问题两张图的亮度、对比度可能不一致尤其是拍摄时有曝光差异重叠区域会有一道明显的“接缝”。处理接缝最简单的办法是渐入渐出融合Linear Blending核心思路是重叠区域里越靠近哪张图就越大程度采用哪张图的像素。具体实现是先计算每张图的掩膜然后对掩膜做距离变换得到权重图最后加权求和。这个方法实现成本低对亮度差异不大的场景效果很不错。如果你的场景有明显视差、运动物体或者曝光差异很大就要考虑更高阶的多频段融合Multi-band Blending但那部分代码量和调参复杂度会高一个量级初学阶段先不用追。还有一个细节很多人不知道在拼接前最好把两张图的亮度做一个整体匹配比如用直方图匹配或者简单的增益补偿。最简单的做法是计算重叠区域的像素均值然后把后一张图的亮度按比例调整到和前一张接近。这样能显著减少融合后的色彩偏差。实际操作中我会先把两张图缩放到相同大小然后计算它们平均亮度的比值对第二张图做一个全局乘法校正效果比直接融合要好很多。2.4 图像拼接核心代码实现下面给出一个可直接运行的图像拼接Demo。我特意把每个关键步骤拆开写方便你对照理解而不是封装成黑盒。这份代码的逻辑是默认左侧图是基准图把右侧图变换到左侧图的坐标系上最后输出一张包含两者的画布。import cv2 import numpy as np def stitch_images(img_left, img_right): # 1. 统一处理尺度避免大图匹配过慢 max_len 1200 h1, w1 img_left.shape[:2] if max(h1, w1) max_len: scale max_len / max(h1, w1) img_left cv2.resize(img_left, (int(w1 * scale), int(h1 * scale))) h2, w2 img_right.shape[:2] if max(h2, w2) max_len: scale max_len / max(h2, w2) img_right cv2.resize(img_right, (int(w2 * scale), int(h2 * scale))) gray1 cv2.cvtColor(img_left, cv2.COLOR_BGR2GRAY) gray2 cv2.cvtColor(img_right, cv2.COLOR_BGR2GRAY) # 2. 提取ORB特征点 orb cv2.ORB_create(nfeatures1500) kp1, des1 orb.detectAndCompute(gray1, None) kp2, des2 orb.detectAndCompute(gray2, None) if len(kp1) 10 or len(kp2) 10: return None # 3. 用交叉匹配筛选 bf cv2.BFMatcher(cv2.NORM_HAMMING, crossCheckTrue) matches bf.match(des1, des2) # 4. 按距离排序取前60%的优质匹配 matches sorted(matches, keylambda x: x.distance) good matches[: int(len(matches) * 0.6)] # 5. 计算单应性矩阵 # 注意这里把右图(left)变换到左图(right)坐标系 src_pts np.float32([kp2[m.trainIdx].pt for m in good]).reshape(-1, 1, 2) dst_pts np.float32([kp1[m.queryIdx].pt for m in good]).reshape(-1, 1, 2) H, mask cv2.findHomography(src_pts, dst_pts, cv2.RANSAC, 5.0) # 6. 变换右图并创建拼接画布 h1, w1 img_left.shape[:2] h2, w2 img_right.shape[:2] # 计算右图变换后的四个角点 corners np.float32([[0, 0], [0, h2], [w2, h2], [w2, 0]]).reshape(-1, 1, 2) warped_corners cv2.perspectiveTransform(corners, H) # 合并左右图的角点确定画布范围 all_corners np.vstack((warped_corners.reshape(-1, 2), np.float32([[0, 0], [0, h1], [w1, h1], [w1, 0]]))) x_min, y_min np.int0(all_corners.min(axis0)) x_max, y_max np.int0(all_corners.max(axis0)) shift np.array([-x_min, -y_min]) # 平移单应性矩阵使右图画到画布正确位置 H_trans H.copy() H_trans[:, 2] shift # 画布大小 canvas np.zeros((y_max - y_min, x_max - x_min, 3), dtypenp.uint8) cv2.warpPerspective(img_right, H_trans, (canvas.shape[1], canvas.shape[0]), dstcanvas) # 把左图粘贴到对应位置 canvas[shift[1]: shift[1] h1, shift[0]: shift[0] w1] img_left return canvas img_left cv2.imread(left.jpg) img_right cv2.imread(right.jpg) result stitch_images(img_left, img_right) if result is not None: cv2.imwrite(stitch_result.jpg, result) else: print(匹配点不足拼接失败请调整图片或增加特征点数)这段代码是简化版没有做融合直接覆盖拼接适合验证流程。如果你发现接缝明显就在最后一步之后做渐入渐出融合。需要提醒的是cv2.warpPerspective输出的是全幅画布但画布边缘会留下黑色区域后续可以做一次裁剪把纯黑边界去掉输出更干净的成品。3. 答题卡识别从透视矫正到自动判分3.1 图像预处理与轮廓定位答题卡识别的输入通常是一张手机拍摄的照片答题卡在画面里可能旋转、倾斜、透视变形背景里可能还有桌面纹理、阴影、其他纸张。第一步要做的就是在这堆杂乱信息里把真正的答题卡区域“拽”出来。我的流程是灰度化 - 高斯模糊 - Canny边缘检测 - 查找轮廓 - 筛选四边形。灰度化是为了减少颜色通道的计算量高斯模糊是为了抑制噪声Canny是为了得到清晰的边缘图。高斯模糊的核大小这里很有讲究。用(3,3)的话噪声抑制不足边缘图会碎成一片用(7,7)又太大了答题卡上的细线和小字边缘会糊掉不利于后面找轮廓。我实测下来(5,5)是平衡点既能把手机拍摄的噪点压下去又不会丢失答题卡的边框边缘。Canny的阈值同样要会调。50和150是我常用的起点。低阈值50决定了能检测到多弱的边缘设太低了墙壁纹理、纸张褶皱都会变成边缘轮廓数量爆炸设太高了答题卡的边框可能断成几截后面的approxPolyDP就拟合不出完整的四边形了。如果你发现轮廓缺失严重优先把低阈值降到30、20不要先动高阈值。轮廓筛选环节直接用cv2.findContours的RETR_EXTERNAL模式把最外层轮廓提取出来然后按面积从大到小排序。答题卡通常是画面里面积最大的白色矩形区域这个假设在绝大多数拍摄场景下成立。对面积前几名的轮廓依次用cv2.approxPolyDP做多边形逼近去逼近一个四边形的就是答题卡。这里的0.02倍轮廓周长系数是经验值控制逼近的精度太大角点位置偏差大太小可能拟合出五边形六边形。3.2 透视矫正与二值化找到答题卡四边形的四个角点之后还不能直接用。因为手机拍照的角度会让答题卡变成一个不规则的四边形四角点需要先排序成“左上、右上、右下、左下”的顺序再用cv2.getPerspectiveTransform计算透视变换矩阵最终把不规则四边形矫正成规整的矩形。角点排序是一个经典的坑很多人在这翻车。我见过有人直接按轮廓返回的索引顺序用角点结果矫正出来的图像是旋转180度的判分全错。标准解法是先计算每个角点的xy最小值是左上角最大值是右下角再计算每个角点的y-x最小值是右上角最大值是左下角。这样四个角的顺序就稳定了不管图怎么旋转都管用。def order_points(pts): rect np.zeros((4, 2), dtypefloat32) s pts.sum(axis1) # x y rect[0] pts[np.argmin(s)] # 左上 rect[2] pts[np.argmax(s)] # 右下 diff np.diff(pts, axis1).ravel() # y - x rect[1] pts[np.argmin(diff)] # 右上 rect[3] pts[np.argmax(diff)] # 左下 return rect矫正后的图像尺寸我通常用四边形四条边的长度来推算取两条水平边的最大值作为宽两条垂直边的最大值作为高这样能保留原比例不会让字和涂卡框变形。如果你知道答题卡的标准长宽比也可以直接按固定尺寸矫正效果更规整。矫正完再做二值化。这里我推荐用OTSU自动阈值法它会自动计算一个能把前景和背景分开的最佳阈值不需要手动调。我比较喜欢用THRESH_BINARY_INV配合OTSU这样涂卡区域变成白色255空白区域变成黑色0。为什么用INV因为后面判断涂卡区域时统计白色像素的占比更直观——涂得越实白色像素越多判断逻辑就是“白色占比超过阈值就算涂了”。3.3 涂卡区域识别与判分逻辑矫正并二值化后答题卡已经被处理成了一个角度端正、黑白分明的矩形图。接下来的问题变成了怎么知道每个题、每个选项在图像的哪个位置最靠谱的商用方案是在答题卡上预设几个黑色定位标记通常是方形黑块用轮廓检测找到这些标记的坐标然后按固定行列间距计算每个选项格的位置。这个方案对打印偏移、拍照角度都有很好的适应性但需要你设计答题卡时预留标记代码也要多一层。我这里为了演示核心逻辑用固定坐标的方案也就是先假设每一格的位置是均匀分布的从某个起点开始按固定步长切分。判断涂卡状态的核心是统计ROI区域里白色像素的比例。我一般设定一个0.5的阈值白色占比超过50%就认为这一格被涂了。为什么不是直接统计黑色像素因为用了THRESH_BINARY_INV之后涂卡区域是白色空白区域是黑色直接countNonZero得到的就是白色像素数除以ROI总面积就能得到涂卡占比。这个逻辑非常直觉化调起来也快。判分的时候有一个细节容易被忽略同一道题如果检测到两个或以上的选项都被“涂了”不要贸然选一个而是标记为异常。现实中会出现铅笔划到旁边的格子、涂卡没涂干净带出一点笔迹等情况。我的做法是如果一道题只有一个选项的白色占比超过阈值就正常判题如果有多个选项超过阈值记录为“疑似多涂”这一步能帮你发现答题卡的问题而不是给出一个错得离谱的分数。3.4 答题卡识别核心代码实现下面这份代码是完整的答题卡识别Demo从读图、矫正、二值化到判分输出都有可以直接跑通。注意固定坐标的数值start_x、start_y、box_w、box_h这些要按你自己设计的答题卡布局来调。import cv2 import numpy as np 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).ravel() rect[1] pts[np.argmin(diff)] rect[3] pts[np.argmax(diff)] return rect def four_point_transform(image, pts): rect order_points(pts.reshape(4, 2)) (tl, tr, br, bl) rect widthA np.linalg.norm(br - bl) widthB np.linalg.norm(tr - tl) maxWidth max(int(widthA), int(widthB)) heightA np.linalg.norm(tr - br) heightB np.linalg.norm(tl - bl) maxHeight max(int(heightA), int(heightB)) dst np.float32([[0, 0], [maxWidth - 1, 0], [maxWidth - 1, maxHeight - 1], [0, maxHeight - 1]]) M cv2.getPerspectiveTransform(rect, dst) warped cv2.warpPerspective(image, M, (maxWidth, maxHeight)) return warped # 1. 读取并预处理 img cv2.imread(answer_sheet.jpg) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) blur cv2.GaussianBlur(gray, (5, 5), 0) edges cv2.Canny(blur, 50, 150) # 2. 找到答题卡轮廓 contours, _ cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) contours sorted(contours, keycv2.contourArea, reverseTrue) sheet_cnt None for cnt in contours: peri cv2.arcLength(cnt, True) approx cv2.approxPolyDP(cnt, 0.02 * peri, True) if len(approx) 4: sheet_cnt approx break if sheet_cnt is None: print(没有找到答题卡轮廓) exit() # 3. 透视矫正并二值化 warped_gray four_point_transform(gray, sheet_cnt) _, warped_bin cv2.threshold( warped_gray, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU) # 4. 按固定网格切分每个选项区域 num_questions 5 num_options 4 box_w, box_h 80, 40 gap_x, gap_y 20, 40 start_x, start_y 80, 100 choices {} for q in range(num_questions): filled_opts [] for opt in range(num_options): x start_x opt * (box_w gap_x) y start_y q * (box_h gap_y) roi warped_bin[y:y box_h, x:x box_w] white_ratio cv2.countNonZero(roi) / (box_w * box_h) if white_ratio 0.5: filled_opts.append(opt) if len(filled_opts) 1: choices[q] filled_opts[0] elif len(filled_opts) 1: print(f第{q1}题疑似多涂: {filled_opts}) # 5. 与标准答案比对判分 # 0表示A1表示B2表示C3表示D仅示意 answer_key {0: 1, 1: 0, 2: 2, 3: 3, 4: 1} score 0 for q in range(num_questions): if q in choices and choices[q] answer_key[q]: score 1 else: if q in choices: print(f第{q1}题答错识别为{[A,B,C,D][choices[q]]}) else: print(f第{q1}题未填涂) print(f得分: {score} / {num_questions})这份代码跑通后你可以做一个小实验把答题卡的ROI网格画出来看看每个框是否真的框住了对应的选项格。很多情况下位置会偏移尤其打印比例不一样的时候画出来你就知道该往哪个方向调整那几个坐标参数。4. 常见问题与排查技巧实录4.1 图像拼接典型问题与处理方法拼接最常见的失败场景是“匹配点不足findHomography直接报错”。我先帮你排查几个原因第一两张图的重叠区域太小一般要求重叠至少占单张图宽度的20%到30%少于这个比例特征点能匹配上的数量会骤减第二图片太单调比如对着纯白墙壁、大块天空拍两张找不到足够多的角点和纹理点这个只能换场景或者人为加一些参照物第三分辨率太低拍出来的图模糊特征点质量差可以尝试放大输入或者用SIFT替代ORB。拼接结果出现“重影”或者“错位”但程序没报错这种情况多半是两种原因一种是匹配点里有大量误匹配混进去了RANSAC的阈值设得偏松建议把findHomography的重投影阈值从5降到3试试另一种是拍摄时相机有平移不是绕光心旋转导致单应性矩阵这个模型本身就不够用了这种情况下两张图的视差太大单纯靠单应性矩阵无法对齐你需要保证拍摄时尽量原地转动相机或者缩短两张图的拍摄距离差。接缝处一道明显的亮线或暗线是亮度不一致导致的。除了前面说的渐入渐出融合还有一个先手技巧拼接前先对两张图做亮度均衡我就是先计算重叠区域的平均亮度比把第二张图整体乘一个系数再接缝就会小很多。如果做完亮度均衡还有接缝再用融合算法不要一上来就上重型融合。4.2 答题卡识别典型问题与处理方法找不到答题卡轮廓是新手最容易卡住的地方。常见原因有三个一是背景里出现大面积高亮物体比如白墙、白纸它们和答题卡混在一起争夺“最大面积四边形”二是答题卡边框颜色太浅边缘检测提取不到完整边界三是透视变形太严重四边形轮廓近似失败。对策也简单——先调Canny低阈值把更多的边缘引出来然后用轮廓面积从大到小逐个尝试不要只盯第一个最后如果背景干扰严重可以考虑用HSV色彩空间单独提取白色区域再找轮廓效果会好很多。透视矫正后图像歪斜大概率是角点排序出问题了。你可以在代码里加一行打印把order_points返回的四个角点按顺序标在图像上看一眼如果发现左上和右下搞反了或者右上和左下搞反了说明排序算法写错了。角点排序是这类项目的经典翻车点排查往这个方向想大概率没错。涂卡识别不准分两种情况一种是很浅的铅笔痕白色占比达不到0.5的阈值把涂卡检测阈值调到0.3左右就行另一种是涂卡区域有杂点比如橡皮屑、铅笔碎屑落在格子里导致误判。处理方法是先对二值化图像做一次形态学开运算cv2.morphologyEx把小的杂点去掉再统计白色占比。这个方法我在实际测试中能把误判率明显压下来。光照不均的问题也很常见尤其是手机拍摄时答题卡一侧有阴影。全局OTSU对这种情况力不从心阴影区域整体二值化后发黑涂卡区域可能整个消失。简单的解法是在矫正之后把图像按行或按块分别做OTSU而不是全局做一次更稳的办法是用cv2.adaptiveThreshold做自适应阈值但参数需要反复试。如果你在室内灯光均匀的地方拍摄这个坑大概率不会遇到。4.3 参数调优速查表场景 / 参数推荐值调整方向ORB特征点数 nfeatures1500匹配点不足时加到3000太多匹配慢就降到1000findHomography重投影阈值5.0拼接错位明显时降到3.0匹配点少时放宽到8.0Canny低/高阈值50 / 150边缘断续时降低低阈值到30或20approxPolyDP epsilon系数0.02角点定位不准时调到0.01到0.03之间二值化模式THRESH_BINARY_INV OTSU让涂卡区域为白色方便用countNonZero统计涂卡白色占比阈值0.5铅笔浅涂时降到0.3杂点偏多时提到0.6高斯模糊核大小(5, 5)图像噪声大用(7,7)边缘细节图用(3,3)5. 实操中的一些体会和扩展建议最后留两句实在话。我最初做这两个项目的时候第一次跑通图像拼接花了整整半天问题不是出在算法上而是两张图的拍摄重叠区域太小特征点数量不够findHomography直接崩了答题卡识别第一次跑也是翻车透视矫正的角点排序逻辑写反了矫正后图像上下颠倒判分结果全错。这些坑写出来就是想告诉你——CV项目的难点很多时候不在算法原理多高深而在边界条件和参数细节。你照着代码跑通了之后强烈建议自己画一张答题卡、拍两张照片试试把参数来回调一调看看每改一个值结果怎么变。我常做的调试办法是写一个临时脚本把所有中间结果——灰度图、边缘图、矫正图、二值化图、ROI网格图——全部保存下来拼在一张图里看。看到哪一步不对劲就改哪一步的参数。这一轮折腾下来你对OpenCV的理解会扎实很多。如果你还想着手扩展这两个项目方向也给到你图像拼接可以往“多图自动全景拼接”走解决批量图片排序、累计误差漂移的问题答题卡识别可以往“答题卡设计器”走做一个工具用来生成自定义布局的答题卡并同步完成识别形成闭环。这两个方向都是不错的进阶挑战。