最近在学OpenCV,整理了三个小项目的笔记,都是比较经典的图像处理案例,记录一下思路和踩过的坑。
一、图像拼接(SIFT特征匹配 + 透视变换)
把两张有重叠区域的图拼成一张全景图,核心思路是:找特征点 → 匹配 → 算变换矩阵 → 把一张图贴到另一张上。
本节知识点
- SIFT特征提取:找图片里的关键点和描述符,不随缩放、旋转变化,用来做图像匹配很稳
- KNN匹配 + 比值测试:用BFMatcher做k近邻匹配,取最近距离和次近距离的比值小于阈值(一般0.65左右)的配对,能过滤掉大量误匹配
- findHomography单应性矩阵:根据匹配点对算出两张图之间的透视变换关系,RANSAC方法可以进一步剔除外点
- warpPerspective透视变换:把一张图按照变换矩阵"掰"到另一张图的视角上
原图
左图A:

右图B:

完整代码
python
import cv2
import numpy as np
import sys
def cv_show(name, img):
cv2.imshow(name, img)
cv2.waitKey(0)
def detectAndDescribe(image):
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) # 转灰度图
sift = cv2.SIFT_create() # 建立SIFT生成器
# 检测SIFT特征点,并计算描述符,第二个参数为掩膜
(kps, des) = sift.detectAndCompute(gray, None)
# 将结果转换成NumPy数组
kps_float = np.float32([kp.pt for kp in kps])
# kp.pt 包含关键点的 x 和 y 坐标,通常是浮点数
return (kps, kps_float, des) # 返回特征点集,具体坐标,对应的特征描述符
'''读取拼接图片'''
imageA = cv2.imread("A.jpg")
cv_show('imageA', imageA)
imageB = cv2.imread("B.jpg")
cv_show('imageB', imageB)
'''计算图片特征点及描述符'''
(kpsA, kps_floatA, desA) = detectAndDescribe(imageA)
(kpsB, kps_floatB, desB) = detectAndDescribe(imageB)
'''建立暴力匹配器BFMatcher,匹配大型训练集合时用FlannBasedMatcher速度更快。'''
matcher = cv2.BFMatcher()
rawMatches = matcher.knnMatch(desB, desA, k=2)
good = []
matches = []
for m in rawMatches:
# 当最近距离跟次近距离的比值小于0.65时,保留此匹配对
if len(m) == 2 and m[0].distance < 0.65 * m[1].distance:
good.append(m)
# 存储两个点在featuresA, featuresB中的索引值
matches.append((m[0].queryIdx, m[0].trainIdx))
print(len(good))
print(matches)
# 绘制匹配结果
vis = cv2.drawMatchesKnn(imageB, kpsB, imageA, kpsA, good, outImg=None,
flags=cv2.DRAW_MATCHES_FLAGS_DRAW_RICH_KEYPOINTS)
cv_show("Keypoint Matches", vis)
'''透视变换'''
if len(matches) > 4: # 匹配对大于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, ransacReprojThreshold=10)
else:
print('图片未找到4个以上的匹配点!')
sys.exit()
# 把imageB变换到imageA的坐标系下,宽度是两张图相加
result = cv2.warpPerspective(imageB, H,
dsize=(imageB.shape[1] + imageA.shape[1], imageA.shape[0]))
cv_show('resultB', result)
# 将imageA贴到结果图最左端
result[0:imageA.shape[0], 0:imageA.shape[1]] = imageA
cv_show('result', result)
cv2.imwrite('pingjie.jpg', result)
拼接结果

笔记
- SIFT的返回值 :
detectAndCompute返回的kps是KeyPoint对象列表,不是直接的坐标,要通过kp.pt拿到(x,y),再转成float32数组才能给findHomography用。 - knnMatch的顺序 :代码里是
knnMatch(desB, desA, k=2),意思是在desA里找desB的最近邻,所以queryIdx对应B的索引,trainIdx对应A的索引,后面取坐标的时候别搞反了。 - 比值测试阈值0.65:这个值越小匹配越严格但数量越少,越大匹配越多但误匹配也多,0.65~0.75是常用范围。
- warpPerspective的dsize:输出图的宽度要设成两张图宽度之和,高度取A的高度,不然变换后的B会被裁掉。
- 直接覆盖拼接 :最后一步
result[0:h, 0:w] = imageA是最简单的拼接方式,重叠区域没有做融合,会有明显接缝,进阶可以用拉普拉斯金字塔融合。
二、答题卡识别(轮廓检测 + 透视矫正 + 阈值判读)
这个项目模拟考试读卡机的功能:拍一张答题卡照片,自动识别每道题选了什么,然后和正确答案比对打分。
本节知识点
- 轮廓检测findContours:在二值图上找物体边界,配合approxPolyDP做轮廓近似,可以筛选出四边形、圆形等特定形状
- 透视变换矫正:找到文档的四个角点后,用getPerspectiveTransform + warpPerspective把倾斜的文档拉正
- 阈值处理THRESH_OTSU:大津法自动找最佳阈值,不用手动调参
- 掩模bitwise_and:生成一个黑白遮罩,和原图做按位与,只保留遮罩白色区域的内容
处理流程
- 预处理(灰度 → 高斯模糊 → Canny边缘)
- 找答题卡外轮廓(四边形),透视变换拉正
- 二值化,找所有圆圈轮廓
- 按大小和宽高比筛选出选项圆圈
- 从上到下、从左到右排序,每5个一组
- 每组里像素最多的那个就是被涂黑的选项
- 和正确答案比对,计分
完整代码
python
import numpy as np
import cv2
ANSWER_KEY = {0: 1, 1: 4, 2: 0, 3: 3, 4: 1} # 正确答案
def order_points(pts):
# 一共4个坐标点,排序为左上、右上、右下、左下
rect = np.zeros((4, 2), dtype="float32")
s = pts.sum(axis=1) # x+y
rect[0] = pts[np.argmin(s)] # 左上:和最小
rect[2] = pts[np.argmax(s)] # 右下:和最大
diff = np.diff(pts, axis=1) # y-x
rect[1] = pts[np.argmin(diff)] # 右上:差最小
rect[3] = pts[np.argmax(diff)] # 左下:差最大
return rect
def four_point_transform(image, 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(image, M, dsize=(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))
return cnts, boundingBoxes
def cv_show(name, img):
cv2.imshow(name, img)
cv2.waitKey(0)
# ========== 预处理 ==========
image = cv2.imread(r'./images/test_01.png')
contours_img = image.copy()
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
blurred = cv2.GaussianBlur(gray, ksize=(5, 5), sigmaX=0)
edged = cv2.Canny(blurred, threshold1=75, threshold2=200)
# ========== 轮廓检测 ==========
cnts = cv2.findContours(edged.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)[-2]
cv2.drawContours(contours_img, cnts, -1, color=(0, 0, 255), thickness=3)
docCnt = None
# 按面积排序,找四边形轮廓
cnts = sorted(cnts, key=cv2.contourArea, reverse=True)
for c in cnts:
peri = cv2.arcLength(c, closed=True)
approx = cv2.approxPolyDP(c, 0.02 * peri, closed=True) # 轮廓近似
if len(approx) == 4:
docCnt = approx
break
# ========== 透视变换矫正 ==========
warped_t = four_point_transform(image, docCnt.reshape(4, 2))
warped_new = warped_t.copy()
warped = cv2.cvtColor(warped_t, cv2.COLOR_BGR2GRAY)
# ========== 阈值处理(大津法自动阈值) ==========
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]
warped_Contours = cv2.drawContours(warped_t, cnts, -1, color=(0, 255, 0), thickness=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]
correct = 0
# 每排5个选项,逐题判断
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,统计非零像素数
mask = np.zeros(thresh.shape, dtype="uint8")
cv2.drawContours(mask, [c], -1, 255, -1) # -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_new, [cnts[k]], -1, color, thickness=3)
score = (correct / 5.0) * 100
print("[INFO] score: {:.2f}%".format(score))
cv2.putText(warped_new, "{:.2f}%".format(score), (10, 30),
cv2.FONT_HERSHEY_SIMPLEX, fontScale=0.9, color=(0, 0, 255), thickness=2)
cv2.imshow("Original", image)
cv2.imshow("Exam", warped_new)
cv2.waitKey(0)
笔记
- order_points排序逻辑 :四个角点怎么区分左上右下?用
x+y的和最小是左上、最大是右下;用y-x的差最小是右上、最大是左下。这个技巧很实用,记下来。 - approxPolyDP轮廓近似 :参数
0.02 * peri表示近似精度是周长的2%,值越小近似越精细。找到近似后顶点数为4的就是答题卡外框。 - THRESH_OTSU :大津法会自动计算最佳阈值,所以threshold第一个参数传0就行,配合
THRESH_BINARY_INV把涂黑的圆圈变成白色、背景变成黑色,方便后续countNonZero。 - 筛选圆圈的条件 :
w>=20, h>=20, 0.9<=ar<=1.1,宽高比接近1说明是圆形。这个阈值要根据实际图片尺寸调整,图片分辨率变了条件也要跟着改。 - 判断涂黑的原理:被涂黑的圆圈在二值图里白色像素最多,所以对每个选项做mask后统计非零像素数,最大的那个就是用户选的答案。没涂的圆圈只有轮廓一圈像素,数量明显少。
- findContours返回值 :OpenCV不同版本返回值个数不一样,用
[-2]取轮廓是兼容写法,不管返回3个还是2个值都能拿到轮廓列表。
三、作业:扇子区域提取(旋转 + Canny + 轮廓掩模)
这个是课后作业,要求把一张扇子图片中的扇子主体抠出来。思路是旋转摆正 → 边缘检测 → 找最大轮廓 → 生成掩模 → 按位与提取。
本节知识点
- np.rot90旋转:numpy的旋转函数,k=1逆时针90度,旋转后宽高会互换
- Canny边缘检测:先高斯模糊去噪,再用双阈值检测边缘,threshold1是低阈值、threshold2是高阈值
- 轮廓筛选:按面积过滤小轮廓,再取最大的那个作为目标外轮廓
- 掩模生成与bitwise_and:全黑背景上填充白色轮廓作为遮罩,和原图按位与只保留遮罩区域
原图

完整代码
python
"""
一张名为fan.jpg的图片,现要求使用 Python 结合 OpenCV 库编写代码实现以下功能:
(1)读取名为fan.jpg的图片,将尺寸设置为宽640,高480,然后逆时针旋转90度;
(2)使用Canny边缘检测提取(1)处理后的边缘;
(3)在提取边缘的基础上,查找轮廓并选取扇子的外轮廓,生成相应的掩模;
(4)将步骤1处理后的图像与步骤3生成的掩模执行按位与操作,提取扇子区域图像,
最终将结果保存为shanzi.png文件。
"""
import cv2
import numpy as np
def cv_show(name, img):
cv2.imshow(name, img)
cv2.waitKey(0)
# (1)读取、缩放、逆时针旋转90度
fan = cv2.imread('fan.jpg')
fan = cv2.resize(fan, (640, 480))
fan_all = np.rot90(fan, k=1) # k=1逆时针90度,k=2就是180度
# (2)Canny边缘检测(先高斯模糊去噪)
blurred = cv2.GaussianBlur(fan_all, ksize=(5, 5), sigmaX=0)
edged = cv2.Canny(blurred, threshold1=75, threshold2=200)
# (3)查找轮廓,选取扇子外轮廓
cnts = cv2.findContours(edged, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)[-2]
draw_fan = fan_all.copy()
cv2.drawContours(draw_fan, cnts, -1, color=(0, 0, 255), thickness=3)
# 先按面积过滤掉小轮廓,再取最大的那个
cntss = [c for c in cnts if cv2.contourArea(c) > 4000]
fan_contour = max(cntss, key=cv2.contourArea)
fan_draw = fan_all.copy()
cv2.drawContours(fan_draw, [fan_contour], -1, (0, 255, 0), 2)
# 生成掩模:黑色背景上把扇子轮廓填充为白色
mask = np.zeros(fan_all.shape[:2], dtype="uint8")
cv2.drawContours(mask, [fan_contour], -1, 255, -1) # -1表示填充
# (4)按位与提取扇子区域
fan_mask = cv2.bitwise_and(fan_all, fan_all, mask=mask)
cv2.imwrite('fan_mask.png', fan_mask)
cv2.destroyAllWindows()
提取结果

笔记
- np.rot90旋转 :
np.rot90(fan, k=1)是逆时针旋转90度,k取负数就是顺时针。注意rot90是numpy的函数,旋转后宽高会互换,所以先resize再旋转。 - CHAIN_APPROX_NONE vs SIMPLE :这里用的是
CHAIN_APPROX_NONE,保留所有轮廓点;如果用SIMPLE会压缩水平/垂直/对角线上的冗余点。做掩模填充两种都可以,但NONE点更密,轮廓更光滑。 - 面积过滤+取最大:直接取最大轮廓不一定靠谱,因为图片边缘可能产生一个超大的噪声轮廓。先过滤掉面积小于4000的小轮廓,再在剩下的里面取最大的,更稳。
- 掩模生成技巧 :
np.zeros建一个全黑图,用drawContours的thickness=-1把扇子轮廓填充成白色,这个白色区域就是后续要保留的部分。 - bitwise_and的用法 :
cv2.bitwise_and(fan_all, fan_all, mask=mask),前两个参数都是原图(自己和自己与结果不变),关键是mask参数------只在mask为白色(非零)的位置执行与操作,黑色区域输出全黑,这样就把扇子抠出来了。
总结
这三个项目覆盖了OpenCV图像处理的一条主线:预处理(灰度/模糊/边缘)→ 特征/轮廓提取 → 几何变换(透视/旋转)→ 区域操作(掩模/拼接)。
几个反复用到的关键点:
- 透视变换需要至少4对点,
findHomography用RANSAC能自动剔除外点 - 轮廓检测后一定要做筛选(面积、宽高比、顶点数),不然噪声轮廓会干扰结果
- 掩模+bitwise_and是抠图的标准操作,简单好用
findContours(...)[-2]是兼容不同OpenCV版本的写法
以上就是这次的学习笔记,代码都跑通过了,有问题欢迎交流。