OpenCV实战学习记录:图像拼接与答题卡识别

8月25日的课程主要学习了两个 OpenCV 实战项目:图像拼接和答题卡识别。结合视频中的代码讲解,我对 SIFT 特征匹配、透视变换、轮廓检测、二值化和掩膜运算等内容有了更具体的认识。

这次课程让我感受到,计算机视觉项目通常不是依靠某一个函数完成的,而是将多个图像处理步骤组合起来,最终实现完整功能。

一、图像拼接

图像拼接的目标,是将两张存在重叠区域的图片合成为一张更大的图片。代码中使用 SIFT 算法提取图像特征点,再通过特征匹配找到两张图片之间的对应关系。

首先定义特征提取函数:

复制代码
def detectAndDescribe(image):
    gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)

    descriptor = cv2.SIFT_create()
    kps, des = descriptor.detectAndCompute(gray, None)

    kps_float = np.float32([kp.pt for kp in kps])
    return kps, kps_float, des

这里先将彩色图像转换为灰度图,再使用 SIFT 检测关键点并计算描述符。关键点可以理解为图像中比较有代表性的位置,例如角点、纹理明显的区域等;描述符则用于描述关键点周围的局部特征。

读取两张图片后,分别提取它们的特征:

复制代码
imageA = cv2.imread("1.jpg")
imageB = cv2.imread("2.jpg")

kpsA, kps_floatA, desA = detectAndDescribe(imageA)
kpsB, kps_floatB, desB = detectAndDescribe(imageB)

接下来使用 BFMatcher 进行特征匹配:

复制代码
matcher = cv2.BFMatcher()
rawMatches = matcher.knnMatch(desB, desA, 2)

这里使用 KNN 方法,为每一个特征点寻找两个最相近的匹配点。为了减少错误匹配,代码采用距离比例进行筛选:

复制代码
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))

最近匹配点与次近匹配点之间的距离差距越大,说明这个匹配越可靠。0.65 是本次代码中设置的经验阈值。如果阈值太小,可能会丢失一些有效匹配;如果阈值太大,又可能保留错误匹配。

视频中还演示了如何将匹配结果画出来:

复制代码
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:
    ptsB = np.float32([kps_floatB[i] for (i, _) in matches])
    ptsA = np.float32([kps_floatA[i] for (_, i) in matches])

    H, mask = cv2.findHomography(
        ptsB, ptsA,
        cv2.RANSAC,
        10
    )

findHomography() 用来计算两张图片之间的透视变换关系。这里使用 RANSAC 算法,可以尽量排除错误匹配点,提高变换矩阵的准确性。

得到矩阵之后,对第二张图片进行透视变换:

复制代码
result = cv2.warpPerspective(
    imageB,
    H,
    (imageB.shape[1] + imageA.shape[1],
     imageB.shape[0])
)

result[0:imageA.shape[0], 0:imageA.shape[1]] = imageA
cv_show("result", result)

最终将第一张图片放到结果图像的左侧,就可以得到拼接后的大图。

图像拼接的主要流程是:

读取图片

灰度化

SIFT提取特征

BFMatcher特征匹配

筛选优质匹配点

RANSAC计算单应性矩阵

透视变换

合成图片

通过这个项目,我理解了图像拼接并不是简单地把两张图片连接起来,而是要先找到它们之间的几何关系,再进行坐标转换。

二、答题卡识别

第二个项目是答题卡识别。程序需要从一张答题卡图片中找到答题区域,判断每道题被涂黑的选项,并与标准答案比较,最后计算分数。

代码首先设置标准答案:

复制代码
ANSWER_KEY = {
    0: 1,
    1: 4,
    2: 0,
    3: 3,
    4: 1
}

为了处理拍摄角度不正的问题,程序定义了四点排序和透视变换函数:

复制代码
def order_points(pts):
    # 一共4个坐标点
    rect = np.zeros(shape=(4, 2), dtype="float32")  # 用来存储排序之后的坐标位置
    # 按顺序找到对应坐标0123分别是 左上,右上,右下,左下
    s = pts.sum(axis=1)  # 对pts矩阵的每一行进行求和操作。(x+y)
    rect[0] = pts[np.argmin(s)]
    rect[2] = pts[np.argmax(s)]

    diff = np.diff(pts, axis=1)  # 对pts矩阵的每一行进行求差操作。(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

    # 计算输入的w和h值
    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

主程序先进行灰度化、高斯模糊和边缘检测:

复制代码
image = cv2.imread("./images/test_01.png")
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)

blurred = cv2.GaussianBlur(gray, (5, 5), 0)
edged = cv2.Canny(blurred, 75, 200)

高斯模糊可以减少噪声,Canny 算法则用于提取答题卡边缘。

接下来寻找轮廓,并根据面积从大到小排序:

复制代码
cnts = cv2.findContours(
    edged.copy(),
    cv2.RETR_EXTERNAL,
    cv2.CHAIN_APPROX_SIMPLE
)[-2]

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 = cv2.cvtColor(
    warped_t,
    cv2.COLOR_BGR2GRAY
)

thresh = cv2.threshold(
    warped,
    0,
    255,
    cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU
)[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

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)

被涂黑的选项通常会包含更多白色像素,因此只需要找到 total 最大的选项,就可以确定考生的答案。

最后将识别结果与标准答案进行比较:

复制代码
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
)

正确答案用绿色标记,错误答案用红色标记。课程视频中还演示了 maskthresh_mask_and 等中间结果,可以比较清楚地看到程序是如何提取每个选项区域的。

最终通过下面的公式计算分数:

复制代码
score = (correct / 5.0) * 100
print("[INFO] score: {:.2f}%".format(score))

三、学习体会

这次课程的视频内容主要围绕代码运行过程展开,从图像预处理到最终结果,每一步都有对应的中间图像。通过实际运行,我发现 OpenCV 项目最重要的不只是记住函数,更要理解每个步骤解决了什么问题。

图像拼接解决的是两张图片之间的位置对应问题,答题卡识别解决的是目标区域定位和像素统计问题。虽然应用场景不同,但它们都使用了灰度化、轮廓或特征提取、坐标排序和图像变换等思想。

另外,参数调试也非常重要。例如图像拼接中的匹配比例阈值、RANSAC 阈值,以及答题卡识别中的轮廓大小和宽高比,都会直接影响最终结果。实际开发时,需要根据图片质量和拍摄环境不断调整。

总的来说,这次学习让我对 OpenCV 的理解从单个函数提升到了完整流程。以后还可以继续尝试实现图像抠图,例如创建目标轮廓的 mask,再利用位运算提取图片中的手机、猫或狗。通过不断完成这些小项目,逐步提升自己的计算机视觉实践能力。

相关推荐
red_redemption41 分钟前
自由学习记录(221)
学习·renderdoc
腾视科技-AIoT41 分钟前
腾视科技AIBOX双版本重磅发布!本地安全与全球适配,解锁视频智能新可能
大数据·人工智能·科技·ai·物理ai·ainas·腾视科技
zcmodeltech42 分钟前
煤化工沙盘模型控制系统设计与实现:多工段协同联动方案
网络·人工智能·stm32·嵌入式硬件·制造·多分类
CTA终结者43 分钟前
2026年程序员量化开发学习:用示例、拆解和练习入门
人工智能·python
小酒星小杜44 分钟前
如何简单地创建你的第一部漫画?从一个“可见变化”开始
人工智能·python·产品
掘金酱1 小时前
TRAE Work 实战帮征文 | 获奖名单公示
前端·人工智能·后端
sel_91 小时前
【OPD论文导读(二)】OPD(On-Policy Distillation)全景调研:十篇论文讲透“在自己生成的内容上学习“这件事
人工智能·python·深度学习·学习·算法·语言模型
动词ing1 小时前
【学习笔记】数据结构(数组长度和关键特性+快慢指针+左右指针)
数据结构·笔记·学习
OBiO20131 小时前
如何构建肺动脉高压动物模型?AAV靶向基因调控造模新思路
人工智能