【计算机视觉入门】两大 OpenCV 案例:答题卡判分与图像全景拼接

文章目录

  • 一、案例一:答题卡自动识别与判分
    • [1.1 案例概述](#1.1 案例概述)
    • [1.2 核心技术栈](#1.2 核心技术栈)
    • [1.3 完整流程与修正后代码](#1.3 完整流程与修正后代码)
    • [1.4 关键步骤详解](#1.4 关键步骤详解)
  • [二、案例二:基于 SIFT 特征的图像拼接](#二、案例二:基于 SIFT 特征的图像拼接)
    • [2.1 案例概述](#2.1 案例概述)
    • [2.2 核心技术栈](#2.2 核心技术栈)
    • [2.3 完整流程与修正后代码](#2.3 完整流程与修正后代码)
    • [2.4 关键步骤详解](#2.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)选项轮廓筛选

通过三个条件筛选选择题选项的圆圈轮廓:

  1. w >= 20 and h >= 20:排除噪点产生的小轮廓
  2. 0.9 <= w/h <= 1.1:长宽比接近 1,确保是圆形或正方形选项框
  3. 配合后续按行排序,每 5 个一组对应一道题

(5)填涂判定原理

对每个选项区域创建掩码(mask),将掩码与二值图做按位与运算 ,只保留该选项区域内的像素,然后用 countNonZero 统计白色像素数量。被填涂的选项区域白色像素数显著高于未填涂的选项,取像素数最大的作为用户选择。


二、案例二:基于 SIFT 特征的图像拼接

2.1 案例概述

图像拼接(Image Stitching)是将多张有重叠区域的照片合成一张全景图的技术。其核心流程为:SIFT 特征点检测与描述 → 特征点匹配(KNN + 比值测试)→ RANSAC 估计单应性矩阵(Homography)→ 透视变换对齐 → 图像融合

本案例将两张有重叠区域的图片 imgA.jpgimgB.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 同时返回:

  • kpsKeyPoint 对象列表,每个包含坐标 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) 计算从 ptsBptsA 的 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)是一种迭代的鲁棒参数估计方法:

  1. 随机选取最小样本集(单应性矩阵需要 4 对点)
  2. 用该子集计算模型参数 H
  3. 统计所有数据点中与模型一致的点(内点,inliers)数量
  4. 重复以上步骤,选取内点最多的模型作为最终结果

与最小二乘法不同,RANSAC 能在外点(误匹配)比例高达 50% 以上的情况下仍准确估计模型,是图像拼接、SLAM 等领域的标准算法。


四、总结

本文通过两个案例覆盖了计算机视觉入门阶段的核心技术:

  • 答题卡识别 案例串联了图像预处理(灰度化、高斯模糊、Canny)→ 轮廓检测与多边形近似 → 透视变换矫正 → OTSU 二值化 → 掩码像素统计判分的完整流水线,重点在于理解透视变换的数学原理和轮廓筛选的工程技巧。

  • 图像拼接 案例涵盖了SIFT 特征检测 → KNN 匹配与比值测试 → RANSAC 单应性矩阵估计 → 透视变换对齐 → 图像融合的核心流程,重点在于理解特征匹配的鲁棒性处理和画布尺寸的动态计算。

相关推荐
樊小肆1 小时前
DeepSeeker-Code源码导读10-代码智能tsHost
人工智能·agent
武子康1 小时前
262K 跑过,128K 却被脚本拦下:A6000 跑分里的三种“失败”不能混为一谈
人工智能·llm·agent
互联网志1 小时前
机器人物理AI操作系统问世 多形态设备协同训练重构智能作业模式
人工智能·重构·机器人
Zguigo1 小时前
【DL】神经网络学习目标|prediction|Loss|Gradient
人工智能·神经网络·学习
长江后浪博客1 小时前
RIP 颜色计算原理:RGB→CMYK 转换与陶瓷喷墨色彩管理
人工智能·rip·颜色管理·陶瓷喷墨
2601_966949651 小时前
Python 量化数据质量校验:如何确保股票历史日线数据不存在缺失交易日?
开发语言·人工智能·爬虫·python·量化策略·量化·quantdash
阳火锅1 小时前
领导夸我日报越写越详细了,其实我只敲了 npm run commit
前端·javascript·人工智能
Aloudata1 小时前
企业级 AI 问数安全指南:如何兼顾可用性、权限与审计?
大数据·人工智能·数据分析·agent·语义编织
广东帝工智能安防1 小时前
BCAS桥梁防撞预警系统五层架构深度解析:从感知层到对接层的技术实现与选型指南
开发语言·人工智能·架构·边缘计算·桥梁防撞预警系统