一、引言:从"特征"到"应用"的跃迁
在上一篇博客中,我们学习了SIFT特征提取和FLANN匹配的基本原理,并通过指纹识别系统完成了身份验证。今天,我们将更进一步------不仅仅满足于"找到匹配点",而是要利用特征匹配技术解决更复杂的实际问题:图像拼接、答题卡识别和目标提取。
这三个项目涵盖了计算机视觉中几个非常重要的应用方向:
图像拼接:将多张有重叠区域的图片合成为一张全景图
答题卡识别:自动检测填涂答案并计算得分
目标提取:从复杂背景中精确分离出感兴趣的对象
本篇博客将结合四个完整的代码示例,带领大家从特征匹配出发,一步步构建出这些实用的视觉应用。这是对前面所学知识------特征检测、透视变换、轮廓分析、掩膜操作------的一次全面综合运用。
二、SIFT特征匹配进阶------可视化与筛选
在开始综合实战之前,我们先完善一下特征匹配的细节:
2.1 匹配点的标注与可视化
python
import cv2
def show(name, img):
cv2.imshow(name, img)
cv2.waitKey(0)
src1 = cv2.imread("src1.bmp")
model = cv2.imread("model.bmp")
sift = cv2.SIFT.create()
kp1, des1 = sift.detectAndCompute(src1, None)
kp2, des2 = sift.detectAndCompute(model, None)
flann = cv2.FlannBasedMatcher()
matches = flann.knnMatch(des1, des2, k=2)
# 筛选匹配点(Lowe's比率测试,阈值0.4)
good = []
alist = []
for m, n in matches:
if m.distance < 0.4 * n.distance:
alist.append((m.queryIdx, m.trainIdx))
good.append([m, n])
# 在两张图上标注匹配点
for i, j in alist:
x1, y1 = kp1[i].pt
x2, y2 = kp2[j].pt
cv2.circle(src1, (int(x1), int(y1)), 3, (0, 0, 255), -1)
cv2.circle(model, (int(x2), int(y2)), 3, (0, 0, 255), -1)
show('Marked_src1', src1)
show('Marked_model', model)
关键点索引说明:
m.queryIdx:在des1中的索引(即src1中的特征点)
m.trainIdx:在des2中的索引(即model中的特征点)
2.2 绘制匹配连线------drawMatchesKnn
cv2.drawMatchesKnn()可以将两张图并排显示,并用线条连接匹配成功的特征点对:
python
matched_img = cv2.drawMatchesKnn(
src1, kp1, model, kp2, good,
outImg=None,
flags=cv2.DrawMatchesFlags_NOT_DRAW_SINGLE_POINTS
)
show('matched_img', matched_img)
参数说明:
flags=cv2.DrawMatchesFlags_NOT_DRAW_SINGLE_POINTS:只绘制有匹配的关键点,不绘制未匹配的点
三、实战一:图像拼接(全景图生成)
图像拼接是特征匹配最经典的应用之一。其核心思路是:找到两张图片中相同的特征点,计算它们之间的变换关系,然后将两张图融合在一起。
3.1 整体流程
图像拼接的完整流程如下:
python
"""
图片A + 图片B
↓
SIFT特征提取(detectAndCompute)
↓
特征匹配(BFMatcher + Lowe's比率测试)
↓
筛选匹配点(至少需要4对)
↓
计算单应性矩阵(findHomography)
↓
透视变换(warpPerspective)
↓
图像融合(拼接)
"""
3.2 代码实现
python
import cv2
import numpy as np
import sys
def detectAndDescribe(image):
"""提取SIFT特征点和描述符"""
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
sift = cv2.SIFT.create()
(kps, des) = sift.detectAndCompute(gray, None)
# 将关键点坐标转换为浮点数数组
kps_float = np.float32([kp.pt for kp in kps])
return (kps, kps_float, des)
# 读取两张待拼接的图片
imageA = cv2.imread("A.jpg")
imageB = cv2.imread("B.jpg")
# 提取特征
(kpsA, kps_floatA, desA) = detectAndDescribe(imageA)
(kpsB, kps_floatB, desB) = detectAndDescribe(imageB)
# 使用暴力匹配器(BFMatcher)
matcher = cv2.BFMatcher()
rawMatches = matcher.knnMatch(desB, desA, 2)
# Lowe's比率测试筛选
good = []
matches = []
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(f"筛选后的匹配点数量: {len(good)}")
# 绘制匹配连线
vis = cv2.drawMatchesKnn(imageB, kpsB, imageA, kpsA, good, None,
flags=cv2.DRAW_MATCHES_FLAGS_DRAW_RICH_KEYPOINTS)
cv2.imshow("Matches", vis)
3.3 计算单应性矩阵与透视变换
当匹配点对数大于4时,可以计算单应性矩阵(Homography Matrix) ,它描述了两张图片之间的投影变换关系:
python
if len(matches) > 4:
# 获取匹配点的坐标
ptsB = np.float32([kps_floatB[i] for (i, _) in matches])
ptsA = np.float32([kps_floatA[i] for (_, i) in matches])
# 计算单应性矩阵(RANSAC方法,可剔除误匹配)
(H, mask) = cv2.findHomography(ptsB, ptsA, cv2.RANSAC, 10)
else:
print('未找到足够的匹配点')
sys.exit()
# 透视变换:将B图变换到A图的空间
result = cv2.warpPerspective(imageB, H,
(imageB.shape[1] + imageA.shape[1],
imageB.shape[0]))
# 将A图放到结果图的最左端
result[0:imageA.shape[0], 0:imageA.shape[1]] = imageA
cv2.imwrite('pingjie.jpg', result)
cv2.findHomography()参数详解:
| 参数 | 含义 |
|---|---|
srcPoints |
源图片中的匹配点坐标(B图) |
dstPoints |
目标图片中的匹配点坐标(A图) |
method |
计算方法:0为最小二乘,cv2.RANSAC为随机采样一致性 |
ransacReprojThreshold |
最大允许重投影误差,默认为3 |
RANSAC(随机采样一致性) 是一种迭代方法,可以从包含大量异常值的数据中估计数学模型参数。在特征匹配中,它能够有效剔除误匹配的点对,得到更准确的变换矩阵。
四、实战二:答题卡识别
答题卡识别是计算机视觉在教育领域的典型应用。其核心流程包括:透视矫正→轮廓检测→选项识别→自动评分。
4.1 透视矫正
首先,检测答题卡的四个角点,通过透视变换将其拉正:
python
def four_point_transform(img, pts):
# 对四个顶点排序(左上、右上、右下、左下)
rect = order_points(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(img, M, (maxWidth, maxHeight))
return warped
# 检测最大的轮廓(答题卡的外边框)
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 = four_point_transform(image, docCnt.reshape(4, 2))
4.2 检测填涂选项
透视变换后,检测每个选项圆圈,并通过掩膜判断该选项是否被填涂:
python
# 阈值处理(注意使用THRESH_BINARY_INV,因为答题卡是白纸黑字)
thresh = cv2.threshold(warped, 0, 255,
cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU)[1]
# 检测轮廓
cnts = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)[-2]
# 筛选出圆形(宽高比接近1,大小合适)
questionCnts = []
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)
# 按从上到下、从左到右排序
questionCnts = sort_contours(questionCnts, method="top-to-bottom")[0]
4.3 判断填涂答案并评分
每一行有5个选项(A、B、C、D、E),遍历每一行,找出被填涂的选项:
python
ANSWER_KEY = {0: 1, 1: 4, 2: 0, 3: 3, 4: 1} # 正确答案
correct = 0
for (q, i) in enumerate(np.arange(0, len(questionCnts), 5)):
cnts = sort_contours(questionCnts[i:i+5])[0]
bubbled = None
for (j, c) in enumerate(cnts):
# 创建掩膜,只保留当前圆圈区域
mask = np.zeros(thresh.shape, dtype='uint8')
cv2.drawContours(mask, [c], -1, 255, -1)
# 统计该区域内非零像素的数量(即填涂的面积)
thresh_mask_and = cv2.bitwise_and(thresh, thresh, mask=mask)
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, [cnts[k]], -1, color, 3)
score = (correct / 5.0) * 100
print(f"得分: {score:.2f}%")
cv2.putText(warped, f"{score:.2f}%", (10, 30),
cv2.FONT_HERSHEY_SIMPLEX, 0.9, (0, 0, 255), 2)
cv2.countNonZero():统计图像中非零像素的数量。在掩膜区域中,填涂区域的像素值为255,未填涂的区域为0。通过统计非零像素数,可以判断该选项是否被填涂。
五、实战三:扇子提取------掩膜操作实战
最后一个项目展示了如何使用轮廓检测+掩膜从复杂背景中精确提取目标物体。
5.1 整体流程
-
读取图片,调整尺寸并旋转
-
灰度化 + 高斯滤波 + Canny边缘检测
-
查找轮廓,按面积排序取最大的外轮廓
-
创建掩膜,绘制轮廓并填充
-
按位与操作提取目标区域
5.2 代码实现
python
import cv2
import numpy as np
fan = cv2.imread("fan.jpg")
fan = cv2.resize(fan, (640, 480))
fan = cv2.rotate(fan, cv2.ROTATE_90_COUNTERCLOCKWISE)
# 边缘检测
gray = cv2.cvtColor(fan, cv2.COLOR_BGR2GRAY)
fan_G = cv2.GaussianBlur(gray, (5, 5), 0)
fan_cay = cv2.Canny(fan_G, 100, 200)
# 查找轮廓,按面积排序取最大轮廓
contours = cv2.findContours(fan_cay, cv2.RETR_EXTERNAL,
cv2.CHAIN_APPROX_NONE)[-2]
sorted_contours = sorted(contours, key=cv2.contourArea, reverse=True)
# 创建掩膜(用最大轮廓填充)
mask = np.zeros(fan_cay.shape, dtype="uint8")
cv2.drawContours(mask, sorted_contours, 0, 255, -1)
# 按位与操作提取目标
fan_msk = cv2.bitwise_and(fan, fan, mask=mask)
cv2.imshow('fan_msk', fan_msk)
关键技术说明:
-
cv2.CHAIN_APPROX_NONE:保留所有轮廓点(而非只保留端点),提供最完整的轮廓信息 -
按面积排序取最大轮廓:假定扇子是画面中最大的物体
-
掩膜填充:
cv2.drawContours中thickness=-1表示填充整个轮廓内部
六、知识点总结
| 技术 | 函数 | 应用场景 |
|---|---|---|
| SIFT特征匹配 | cv2.SIFT.create(), detectAndCompute(), knnMatch() |
指纹识别、图像拼接 |
| Lowe's比率测试 | m.distance < 0.8 * n.distance |
筛选高质量匹配对 |
| 绘制匹配连线 | cv2.drawMatchesKnn() |
可视化匹配结果 |
| 单应性矩阵 | cv2.findHomography() |
图像变换、拼接 |
| 透视变换 | cv2.warpPerspective() |
文档矫正、视角变换 |
| 轮廓近似 | cv2.approxPolyDP() |
提取多边形顶点(如矩形) |
| 掩膜与按位与 | cv2.bitwise_and() |
提取感兴趣区域 |