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
)
正确答案用绿色标记,错误答案用红色标记。课程视频中还演示了 mask、thresh_mask_and 等中间结果,可以比较清楚地看到程序是如何提取每个选项区域的。
最终通过下面的公式计算分数:
score = (correct / 5.0) * 100
print("[INFO] score: {:.2f}%".format(score))
三、学习体会
这次课程的视频内容主要围绕代码运行过程展开,从图像预处理到最终结果,每一步都有对应的中间图像。通过实际运行,我发现 OpenCV 项目最重要的不只是记住函数,更要理解每个步骤解决了什么问题。
图像拼接解决的是两张图片之间的位置对应问题,答题卡识别解决的是目标区域定位和像素统计问题。虽然应用场景不同,但它们都使用了灰度化、轮廓或特征提取、坐标排序和图像变换等思想。
另外,参数调试也非常重要。例如图像拼接中的匹配比例阈值、RANSAC 阈值,以及答题卡识别中的轮廓大小和宽高比,都会直接影响最终结果。实际开发时,需要根据图片质量和拍摄环境不断调整。
总的来说,这次学习让我对 OpenCV 的理解从单个函数提升到了完整流程。以后还可以继续尝试实现图像抠图,例如创建目标轮廓的 mask,再利用位运算提取图片中的手机、猫或狗。通过不断完成这些小项目,逐步提升自己的计算机视觉实践能力。