OpenCV图像处理笔记:图像拼接、答题卡识别与目标提取

最近在学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)

拼接结果

笔记

  1. SIFT的返回值detectAndCompute返回的kps是KeyPoint对象列表,不是直接的坐标,要通过kp.pt拿到(x,y),再转成float32数组才能给findHomography用。
  2. knnMatch的顺序 :代码里是knnMatch(desB, desA, k=2),意思是在desA里找desB的最近邻,所以queryIdx对应B的索引,trainIdx对应A的索引,后面取坐标的时候别搞反了。
  3. 比值测试阈值0.65:这个值越小匹配越严格但数量越少,越大匹配越多但误匹配也多,0.65~0.75是常用范围。
  4. warpPerspective的dsize:输出图的宽度要设成两张图宽度之和,高度取A的高度,不然变换后的B会被裁掉。
  5. 直接覆盖拼接 :最后一步result[0:h, 0:w] = imageA是最简单的拼接方式,重叠区域没有做融合,会有明显接缝,进阶可以用拉普拉斯金字塔融合。

二、答题卡识别(轮廓检测 + 透视矫正 + 阈值判读)

这个项目模拟考试读卡机的功能:拍一张答题卡照片,自动识别每道题选了什么,然后和正确答案比对打分。

本节知识点

  • 轮廓检测findContours:在二值图上找物体边界,配合approxPolyDP做轮廓近似,可以筛选出四边形、圆形等特定形状
  • 透视变换矫正:找到文档的四个角点后,用getPerspectiveTransform + warpPerspective把倾斜的文档拉正
  • 阈值处理THRESH_OTSU:大津法自动找最佳阈值,不用手动调参
  • 掩模bitwise_and:生成一个黑白遮罩,和原图做按位与,只保留遮罩白色区域的内容

处理流程

  1. 预处理(灰度 → 高斯模糊 → Canny边缘)
  2. 找答题卡外轮廓(四边形),透视变换拉正
  3. 二值化,找所有圆圈轮廓
  4. 按大小和宽高比筛选出选项圆圈
  5. 从上到下、从左到右排序,每5个一组
  6. 每组里像素最多的那个就是被涂黑的选项
  7. 和正确答案比对,计分

完整代码

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)

笔记

  1. order_points排序逻辑 :四个角点怎么区分左上右下?用x+y的和最小是左上、最大是右下;用y-x的差最小是右上、最大是左下。这个技巧很实用,记下来。
  2. approxPolyDP轮廓近似 :参数0.02 * peri表示近似精度是周长的2%,值越小近似越精细。找到近似后顶点数为4的就是答题卡外框。
  3. THRESH_OTSU :大津法会自动计算最佳阈值,所以threshold第一个参数传0就行,配合THRESH_BINARY_INV把涂黑的圆圈变成白色、背景变成黑色,方便后续countNonZero。
  4. 筛选圆圈的条件w>=20, h>=20, 0.9<=ar<=1.1,宽高比接近1说明是圆形。这个阈值要根据实际图片尺寸调整,图片分辨率变了条件也要跟着改。
  5. 判断涂黑的原理:被涂黑的圆圈在二值图里白色像素最多,所以对每个选项做mask后统计非零像素数,最大的那个就是用户选的答案。没涂的圆圈只有轮廓一圈像素,数量明显少。
  6. 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()

提取结果

笔记

  1. np.rot90旋转np.rot90(fan, k=1)是逆时针旋转90度,k取负数就是顺时针。注意rot90是numpy的函数,旋转后宽高会互换,所以先resize再旋转。
  2. CHAIN_APPROX_NONE vs SIMPLE :这里用的是CHAIN_APPROX_NONE,保留所有轮廓点;如果用SIMPLE会压缩水平/垂直/对角线上的冗余点。做掩模填充两种都可以,但NONE点更密,轮廓更光滑。
  3. 面积过滤+取最大:直接取最大轮廓不一定靠谱,因为图片边缘可能产生一个超大的噪声轮廓。先过滤掉面积小于4000的小轮廓,再在剩下的里面取最大的,更稳。
  4. 掩模生成技巧np.zeros建一个全黑图,用drawContoursthickness=-1把扇子轮廓填充成白色,这个白色区域就是后续要保留的部分。
  5. bitwise_and的用法cv2.bitwise_and(fan_all, fan_all, mask=mask),前两个参数都是原图(自己和自己与结果不变),关键是mask参数------只在mask为白色(非零)的位置执行与操作,黑色区域输出全黑,这样就把扇子抠出来了。

总结

这三个项目覆盖了OpenCV图像处理的一条主线:预处理(灰度/模糊/边缘)→ 特征/轮廓提取 → 几何变换(透视/旋转)→ 区域操作(掩模/拼接)

几个反复用到的关键点:

  • 透视变换需要至少4对点,findHomography用RANSAC能自动剔除外点
  • 轮廓检测后一定要做筛选(面积、宽高比、顶点数),不然噪声轮廓会干扰结果
  • 掩模+bitwise_and是抠图的标准操作,简单好用
  • findContours(...)[-2]是兼容不同OpenCV版本的写法

以上就是这次的学习笔记,代码都跑通过了,有问题欢迎交流。

相关推荐
陈年老古董43 分钟前
CentOS编译安装Python3.11完整教程
linux·笔记·学习·centos·python3.11
智购科技智能售货柜44 分钟前
2026自动售货机峰值交易流量应对方案:从消息削峰到弹性扩容的工程实践~YH
数据库·人工智能·单片机·嵌入式硬件·yolo
深念Y1 小时前
为什么选 Go:直观、可维护、AI 友好
linux·开发语言·人工智能·golang·agent·harness
北京靠谱的GEO优化机构1 小时前
AI时代作品背书:书籍歌曲影视百科创建规则与实操技巧
人工智能·百度·seo·百科创建
像风一样自由20201 小时前
13.pgvector入门用PostgreSQL直接实现向量检
人工智能·postgresql·大模型·rag·智能体
Csvn1 小时前
第 6 章 Agent 主循环
人工智能·aigc·agent
Csvn1 小时前
第 5 章 工具调用
人工智能·aigc·agent
clorinda1 小时前
OpenCV实战学习记录:图像拼接与答题卡识别
人工智能·opencv·学习
腾视科技-AIoT1 小时前
腾视科技AIBOX双版本重磅发布!本地安全与全球适配,解锁视频智能新可能
大数据·人工智能·科技·ai·物理ai·ainas·腾视科技