文章目录
- 一、案例一:答题卡自动识别与判分
-
- [1.1 案例概述](#1.1 案例概述)
- [1.2 核心技术栈](#1.2 核心技术栈)
- [1.3 完整流程与修正后代码](#1.3 完整流程与修正后代码)
- [1.4 关键步骤详解](#1.4 关键步骤详解)
-
- [(1)四点顶点排序 `order_point`](#(1)四点顶点排序
order_point) - (2)透视变换尺寸计算
- [(3)OTSU 反向二值化](#(3)OTSU 反向二值化)
- (4)选项轮廓筛选
- (5)填涂判定原理
- [(1)四点顶点排序 `order_point`](#(1)四点顶点排序
- [二、案例二:基于 SIFT 特征的图像拼接](#二、案例二:基于 SIFT 特征的图像拼接)
-
- [2.1 案例概述](#2.1 案例概述)
- [2.2 核心技术栈](#2.2 核心技术栈)
- [2.3 完整流程与修正后代码](#2.3 完整流程与修正后代码)
- [2.4 关键步骤详解](#2.4 关键步骤详解)
-
- [(1)SIFT 特征检测与描述](#(1)SIFT 特征检测与描述)
- [(2)KNN 匹配与比值测试](#(2)KNN 匹配与比值测试)
- (3)匹配点坐标提取与单应性矩阵
- (4)画布尺寸与图像放置
- 三、补充知识点
-
- [3.1 OpenCV 版本与 SIFT 可用性](#3.1 OpenCV 版本与 SIFT 可用性)
- [3.2 轮廓检测 `findContours` 返回值](#3.2 轮廓检测
findContours返回值) - [3.3 Canny 边缘检测参数](#3.3 Canny 边缘检测参数)
- [3.4 单应性矩阵(Homography)的数学意义](#3.4 单应性矩阵(Homography)的数学意义)
- [3.5 RANSAC 算法简介](#3.5 RANSAC 算法简介)
- 四、总结
一、案例一:答题卡自动识别与判分
1.1 案例概述
自动识别答题卡并判分是计算机视觉在教育领域的典型应用。其核心思路是:通过边缘检测 找到答题卡外框 → 利用透视变换 将倾斜的答题卡矫正为鸟瞰俯视图 → 通过二值化 与轮廓检测定位每个选择题选项 → 统计每个选项区域的黑色像素(填涂区域)数量,判断用户选择 → 与标准答案比对并计算得分。
1.2 核心技术栈
| 技术 | 作用 |
|---|---|
高斯模糊 GaussianBlur |
降噪,减少边缘检测误检 |
| Canny 边缘检测 | 提取图像边缘 |
轮廓检测 findContours |
查找答题卡外框与选项圆圈 |
多边形近似 approxPolyDP |
将轮廓近似为四边形,定位答题卡 |
透视变换 getPerspectiveTransform + warpPerspective |
矫正倾斜答题卡 |
| OTSU 自适应阈值二值化 | 分离填涂区域与背景 |
| 掩码与像素统计 | 判断哪个选项被填涂 |
1.3 完整流程与修正后代码
python
import cv2
import numpy as np
# 标准答案字典:key为题号,value为正确选项下标(0,1,2,3,4)
ANSWER_KEY = {0: 1, 1: 4, 2: 0, 3: 3, 4: 1}
def order_point(pts):
"""对4个顶点按 左上、右上、右下、左下 排序"""
rect = np.zeros((4, 2), dtype="float32")
s = pts.sum(axis=1)
rect[0] = pts[np.argmin(s)] # 左上:x+y 最小
rect[2] = pts[np.argmax(s)] # 右下:x+y 最大
diff = np.diff(pts, axis=1)
rect[1] = pts[np.argmin(diff)] # 右上:y‑x 最小
rect[3] = pts[np.argmax(diff)] # 左下:y‑x 最大
return rect
def four_point_transform(image, pts):
"""四点透视变换,获取答题卡的鸟瞰俯视图"""
rect = order_point(pts)
(tl, tr, br, bl) = rect
# 计算新图像的宽度(取上下两边的较大值)
widthA = np.sqrt(((br[0] - bl[0]) ** 2) + ((br[1] - bl[1]) ** 2))
widthB = np.sqrt(((tr[0] - tl[0]) ** 2) + ((tr[1] - tl[1]) ** 2))
maxWidth = max(int(widthA), int(widthB))
# 计算新图像的高度(取左右两边的较大值)
heightA = np.sqrt(((tr[0] - br[0]) ** 2) + ((tr[1] - br[1]) ** 2))
heightB = np.sqrt(((tl[0] - bl[0]) ** 2) + ((tl[1] - bl[1]) ** 2))
maxHeight = max(int(heightA), int(heightB))
# 目标点:标准矩形的四个角
dst = np.array([
[0, 0],
[maxWidth - 1, 0],
[maxWidth - 1, maxHeight - 1],
[0, maxHeight - 1]
], dtype="float32")
# 计算透视变换矩阵并应用
M = cv2.getPerspectiveTransform(rect, dst)
warped = cv2.warpPerspective(image, M, (maxWidth, maxHeight))
return warped
def sort_contours(cnts, method='left-to-right'):
"""轮廓排序,支持多种排序方向"""
reverse = False
i = 0
if method == 'right-to-left' or method == 'bottom-to-top':
reverse = True
if method == 'top-to-bottom' or method == 'bottom-to-top':
i = 1
boundingBoxes = [cv2.boundingRect(c) for c in cnts]
(cnts, boundingBoxes) = zip(*sorted(zip(cnts, boundingBoxes),
key=lambda b: b[1][i], reverse=reverse))
# zip(*...) 使用星号操作符解包排序后的元组列表,并将其重新组合成两个列表:一个包含所有轮廓,另一个包含所有边界框。
return cnts, boundingBoxes
def cv_show(name, img):
"""封装图像显示函数,按任意键关闭窗口"""
cv2.imshow(name, img)
cv2.waitKey(0)
# 读取原始答题卡图片
image = cv2.imread('test_01.png')
contours_img = image.copy() # 拷贝原图,用于绘制轮廓调试
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) # 转为灰度图
blurred = cv2.GaussianBlur(gray, (5, 5), 0) # 高斯模糊,降噪
cv_show('blurred', blurred)
edged = cv2.Canny(blurred, 75, 200) # Canny边缘检测
cv_show('edged', edged)
# 查找全部外部轮廓
cnts = cv2.findContours(edged.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)[-2]
cv2.drawContours(contours_img, cnts, -1, (0, 0, 255), 3) # 在图上绘制所有轮廓
cv_show('contours_img', contours_img)
docCnt = None
# 按轮廓面积降序,寻找答题卡外框(四边形)
cnts = sorted(cnts, key=cv2.contourArea, reverse=True)
for c in cnts:
peri = cv2.arcLength(c, True) # 计算轮廓周长
approx = cv2.approxPolyDP(c, 0.02 * peri, True) # 轮廓多边形近似
if len(approx) == 4: # 找到四边形,即为答题卡边框
docCnt = approx
break
# 执行四点透视变换,矫正答题卡视角
warped_t = four_point_transform(image, docCnt.reshape(4, 2))
warped_new = warped_t.copy() # 拷贝矫正图,用于后续画答案标记
cv_show('warped', warped_t)
# 矫正后图像转灰度,做二值化处理
warped = cv2.cvtColor(warped_t, cv2.COLOR_BGR2GRAY)
# OTSU自动阈值 + 反向二值化:填涂区域变成白色,背景黑色
thresh = cv2.threshold(warped, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU)[1]
cv_show('thresh', thresh)
thresh_Contours = thresh.copy()
# 在二值图上查找所有外部轮廓
cnts = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)[-2]
warped_Contours = cv2.drawContours(warped_t, cnts, -1, (0, 255, 0), 1)
cv_show('warped_Contours', warped_Contours)
questionCnts = []
# 筛选出选择题选项的轮廓:宽高>=20,长宽比接近1,圆形方框
for c in cnts:
(x, y, w, h) = cv2.boundingRect(c)
ar = w / float(h)
if w >= 20 and h >= 20 and 0.9 <= ar <= 1.1:
questionCnts.append(c)
print(len(questionCnts))
# 将所有选项轮廓从上到下排序,区分每一道题目
questionCnts = sort_contours(questionCnts, method="top-to-bottom")[0]
correct = 0 # 统计答对题目数量
# 每道题5个选项,步长5遍历题目
for (q, i) in enumerate(np.arange(0, len(questionCnts), 5)):
# 取出当前题的5个选项轮廓,做左右排序
cnts = sort_contours(questionCnts[i:i + 5])[0]
bubbled = None # 记录填涂最多的选项:(像素数量,选项下标)
# 遍历本题5个选项
for (j, c) in enumerate(cnts):
mask = np.zeros(thresh.shape, dtype='uint8') # 创建空白掩码
cv2.drawContours(mask, [c], -1, 255, -1) # 将当前选项轮廓填充白色
cv_show('mask', mask)
# 掩码与二值图做与运算,只保留该选项区域像素
thresh_mask_and = cv2.bitwise_and(thresh, thresh, mask=mask)
cv_show('thresh_mask_and', thresh_mask_and)
total = cv2.countNonZero(thresh_mask_and) # 统计该选项白色像素点数
# 像素数更大则更新,代表该选项被填涂
if bubbled is None or total > bubbled[0]:
bubbled = (total, j)
color = (0, 0, 255) # 默认红色,答错
k = ANSWER_KEY[q] # 获取本题标准答案下标
if k == bubbled[1]: # 用户选择与标准答案一致
color = (0, 255, 0) # 改为绿色,答对
correct += 1
cv2.drawContours(warped_new, [cnts[k]], -1, color, 3) # 在图上标记正确选项
cv_show('warpeding', warped_new)
# 计算得分,转为百分比
score = (correct / 5.0) * 100
print(f'[INFO] score:{score:.2f}%')
# 将分数绘制到矫正图像左上角
cv2.putText(warped_new, '{:.2f}%'.format(score), (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.9, (0, 0, 255), 2)
# 窗口展示原图与判分后的答题卡
cv2.imshow('Original', image)
cv2.waitKey(0)
cv2.imshow('Exam', warped_new)
cv2.waitKey(0)
cv2.destroyAllWindows()
结果演示:

1.4 关键步骤详解
(1)四点顶点排序 order_point
透视变换需要知道四个顶点分别对应左上、右上、右下、左下。由于 findContours 返回的顶点顺序是不确定的,需要通过坐标特征进行排序:
- 左上 :
x + y最小 - 右下 :
x + y最大 - 右上 :
y - x最小(x 大、y 小) - 左下 :
y - x最大(x 小、y 大)
其中 np.diff(pts, axis=1) 对每个点的 [x, y] 做差分,结果为 y - x。
(2)透视变换尺寸计算
变换后图像的宽高不能直接取原图宽高,而应取四边形对应边长度的较大值:
- 宽度 =
max(下边长度, 上边长度) - 高度 =
max(右边长度, 左边长度)
这样可以保证变换后的图像完整包含四边形区域,不会丢失边缘像素。
(3)OTSU 反向二值化
python
thresh = cv2.threshold(warped, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU)[1]
THRESH_OTSU:自动计算最优阈值,无需手动指定(此时第二个参数0被忽略)THRESH_BINARY_INV:反向二值化,大于阈值的像素变为 0(黑),小于阈值的变为 255(白)- 答题卡中填涂区域是黑色的(像素值低),反向二值化后变为白色,便于后续用
countNonZero统计
(4)选项轮廓筛选
通过三个条件筛选选择题选项的圆圈轮廓:
w >= 20 and h >= 20:排除噪点产生的小轮廓0.9 <= w/h <= 1.1:长宽比接近 1,确保是圆形或正方形选项框- 配合后续按行排序,每 5 个一组对应一道题
(5)填涂判定原理
对每个选项区域创建掩码(mask),将掩码与二值图做按位与运算 ,只保留该选项区域内的像素,然后用 countNonZero 统计白色像素数量。被填涂的选项区域白色像素数显著高于未填涂的选项,取像素数最大的作为用户选择。
二、案例二:基于 SIFT 特征的图像拼接
2.1 案例概述
图像拼接(Image Stitching)是将多张有重叠区域的照片合成一张全景图的技术。其核心流程为:SIFT 特征点检测与描述 → 特征点匹配(KNN + 比值测试)→ RANSAC 估计单应性矩阵(Homography)→ 透视变换对齐 → 图像融合。
本案例将两张有重叠区域的图片 imgA.jpg 和 imgB.jpg 拼接为一张全景图。
2.2 核心技术栈
| 技术 | 作用 |
|---|---|
SIFT SIFT_create() |
检测尺度不变特征点并生成 128 维描述子 |
| BFMatcher + knnMatch | 暴力匹配特征点,返回 k 个最近邻 |
| 比值测试(Lowe's Ratio Test) | 过滤误匹配,保留优质匹配对 |
findHomography + RANSAC |
计算单应性矩阵,RANSAC 剔除外点 |
warpPerspective |
将图像变换到统一坐标系 |
| 图像融合 | 消除拼接接缝 |
2.3 完整流程与修正后代码
python
import cv2
import numpy as np
import sys
def cv_show(name, img):
"""显示图像并等待按键"""
cv2.imshow(name, img)
cv2.waitKey(0)
def detectAndDescribe(image):
"""检测 SIFT 特征点并计算描述子"""
# 转为灰度图
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
# 创建 SIFT 检测器
sift = cv2.SIFT_create()
# 检测特征点并计算描述子
(kps, des) = sift.detectAndCompute(gray, None)
# 将关键点的坐标 (pt) 提取为 float32 数组,便于后续索引
kps_float = np.float32([kp.pt for kp in kps])
return (kps, kps_float, des)
# 读取两张待拼接的图片
imageA = cv2.imread('imgA.jpg')
cv_show('imageA', imageA)
imageB = cv2.imread('imgB.jpg')
cv_show('imageB', imageB)
# 分别检测两张图的 SIFT 特征
(kpsA, kps_floatA, desA) = detectAndDescribe(imageA)
(kpsB, kps_floatB, desB) = detectAndDescribe(imageB)
# 创建暴力匹配器
matcher = cv2.BFMatcher()
# 使用 kNN 匹配,对每个特征点返回 2 个最近邻
rawMatches = matcher.knnMatch(desB, desA, 2)
# 应用 Lowe 比值测试筛选优质匹配
good = [] # 用于可视化匹配结果
matches = [] # 存储 (queryIdx, trainIdx) 用于计算单应性矩阵
for m in rawMatches:
if len(m) == 2 and m[0].distance < 0.65 * m[1].distance:
good.append(m)
matches.append((m[0].queryIdx, m[0].trainIdx))
print(len(good))
print(matches)
# 可视化匹配结果
vis = cv2.drawMatchesKnn(imageB, kpsB, imageA, kpsA, good, None,
flags=cv2.DRAW_MATCHES_FLAGS_DRAW_RICH_KEYPOINTS)
cv_show('Keypoint Matches', vis)
# 检查匹配点数量是否足够计算单应性矩阵
if len(matches) > 4:
# 从 matches 中提取匹配点的坐标
ptsB = np.float32([kps_floatB[i] for (i, _) in matches])
ptsA = np.float32([kps_floatA[i] for (_, i) in matches])
# 使用 RANSAC 计算单应性矩阵,将 ptsB 映射到 ptsA 坐标系
(H, mask) = cv2.findHomography(ptsB, ptsA, cv2.RANSAC, 10)
else:
print('图片未找到4个及以上的匹配点')
sys.exit()
# 创建结果画布,宽度为两图宽度之和,高度取 imageB 的高度
result = cv2.warpPerspective(imageB, H,
(imageB.shape[1] + imageA.shape[1], imageB.shape[0]))
cv_show('resultB', result)
# 将 imageA 直接覆盖在画布左上角
result[0:imageA.shape[0], 0:imageA.shape[1]] = imageA
cv_show('result', result)
# 保存拼接结果
cv2.imwrite('pinjie.jpg', result)
结果演示:

2.4 关键步骤详解
(1)SIFT 特征检测与描述
SIFT(Scale-Invariant Feature Transform)由 David Lowe 于 2004 年提出,具有尺度不变性 和旋转不变性 。cv2.SIFT_create() 创建检测器,detectAndCompute 同时返回:
kps:KeyPoint对象列表,每个包含坐标pt、尺度size、方向angle等des:描述子数组,形状为(N, 128),N 为特征点数,每个特征点用 128 维向量描述
(2)KNN 匹配与比值测试
python
matcher = cv2.BFMatcher()
rawMatches = matcher.knnMatch(desB, desA, k=2)
BFMatcher:暴力匹配,对 query 描述子逐一与 train 描述子计算距离knnMatch(..., k=2):对每个 query 描述子返回距离最近的 2 个匹配(m[0]最近,m[1]次近)- Lowe 比值测试 :仅当
m[0].distance < ratio * m[1].distance时保留该匹配。原理是:正确匹配的最近邻距离应远小于次近邻(区分度高),而误匹配往往两个最近邻距离相近。ratio通常取 0.65~0.8,越小匹配越严格、匹配点越少。
(3)匹配点坐标提取与单应性矩阵
matches 列表中每个元素为 (queryIdx, trainIdx):
queryIdx:对应desB中的索引(因为knnMatch(desB, desA, 2)中 desB 是 query)trainIdx:对应desA中的索引
因此:
python
ptsB = np.float32([kps_floatB[i] for (i, _) in matches]) # 取 B 中的点
ptsA = np.float32([kps_floatA[i] for (_, i) in matches]) # 取 A 中的点
cv2.findHomography(ptsB, ptsA, cv2.RANSAC, reprojThresh) 计算从 ptsB 到 ptsA 的 3×3 单应性矩阵 H,满足 ptsA ≈ H · ptsB(齐次坐标下)。RANSAC 随机抽样一致性算法能在存在大量误匹配(外点)的情况下稳健估计 H,reprojThresh 是内点判定的重投影误差阈值(像素),通常取 1~5。
(4)画布尺寸与图像放置
本代码采用了一种简化的做法:
python
result = cv2.warpPerspective(imageB, H, (imageB.shape[1] + imageA.shape[1], imageB.shape[0]))
result[0:imageA.shape[0], 0:imageA.shape[1]] = imageA
将画布宽度设为两张图片宽度之和,高度取 imageB 的高度,然后直接将 imageA 覆盖在左上角。这种方法假设变换后的 imageB 位于 imageA 的右侧或上方,且不会出现负坐标。
注意:在实际应用中,imageB 经过透视变换后可能向左或向上偏移,导致部分内容被裁剪;如果 imageA 的高度大于 imageB 的高度,也可能出现越界赋值。因此,该简化版本仅适用于两图位置关系较为标准的场景。
更稳健的做法是计算所有角点变换后的全局边界,动态确定画布大小和偏移量,并进行图像融合以消除接缝。
三、补充知识点
3.1 OpenCV 版本与 SIFT 可用性
| OpenCV 版本 | SIFT 调用方式 | 是否需要 opencv-contrib |
|---|---|---|
| 3.x(早期) | cv2.xfeatures2d.SIFT_create() |
是 |
| 4.4.0 之前 | cv2.xfeatures2d.SIFT_create() |
是 |
| 4.4.0 及以上 | cv2.SIFT_create() |
否(专利已过期) |
若运行时报 module 'cv2' has no attribute 'SIFT_create',可尝试:
bash
pip install opencv-contrib-python
并将代码改为 cv2.xfeatures2d.SIFT_create()。
3.2 轮廓检测 findContours 返回值
OpenCV 不同版本中 findContours 返回值数量不同:
- OpenCV 2.x :返回
(contours, hierarchy)--- 2 个值 - OpenCV 3.x / 4.x :返回
(image, contours, hierarchy)--- 3 个值
代码中使用 [-2] 取倒数第二个元素,是兼容两种版本的写法(无论返回 2 个还是 3 个值,contours 始终是倒数第二个)。
3.3 Canny 边缘检测参数
python
edged = cv2.Canny(blurred, 75, 200)
- 第二个参数
75:低阈值(minVal) - 第三个参数
200:高阈值(maxVal) - 梯度值 > maxVal 的点保留为边缘;< minVal 的点丢弃;介于两者之间的点仅当与确定边缘相连时保留
- 推荐比例
maxVal : minVal ≈ 2:1 或 3:1
3.4 单应性矩阵(Homography)的数学意义
单应性矩阵 H 是一个 3×3 矩阵,描述同一平面在两个不同视角下的投影映射关系。在齐次坐标下:
[x'] [h11 h12 h13] [x]
[y'] = [h21 h22 h23] [y]
[w'] [h31 h32 h33] [1]
实际像素坐标为 (x'/w', y'/w')。H 有 8 个自由度(尺度不变,通常令 h33=1),因此至少需要 4 对匹配点 才能求解。这也是代码中判断 len(matches) > 4 的原因。
3.5 RANSAC 算法简介
RANSAC(Random Sample Consensus)是一种迭代的鲁棒参数估计方法:
- 随机选取最小样本集(单应性矩阵需要 4 对点)
- 用该子集计算模型参数 H
- 统计所有数据点中与模型一致的点(内点,inliers)数量
- 重复以上步骤,选取内点最多的模型作为最终结果
与最小二乘法不同,RANSAC 能在外点(误匹配)比例高达 50% 以上的情况下仍准确估计模型,是图像拼接、SLAM 等领域的标准算法。
四、总结
本文通过两个案例覆盖了计算机视觉入门阶段的核心技术:
-
答题卡识别 案例串联了图像预处理(灰度化、高斯模糊、Canny)→ 轮廓检测与多边形近似 → 透视变换矫正 → OTSU 二值化 → 掩码像素统计判分的完整流水线,重点在于理解透视变换的数学原理和轮廓筛选的工程技巧。
-
图像拼接 案例涵盖了SIFT 特征检测 → KNN 匹配与比值测试 → RANSAC 单应性矩阵估计 → 透视变换对齐 → 图像融合的核心流程,重点在于理解特征匹配的鲁棒性处理和画布尺寸的动态计算。