从发票矫正到特征提取:OpenCV 图像处理实战详解

目录

  1. 引言
  2. 项目背景与目标
  3. 环境准备与基础函数
  4. 发票图像的预处理与轮廓检测
    • 4.1 图像缩放
    • 4.2 灰度化与二值化
    • 4.3 轮廓查找与绘制
  5. 发票区域提取:最大轮廓与多边形近似
  6. 透视变换:将发票摆正
    • 6.1 坐标点排序
    • 6.2 变换矩阵计算
    • 6.3 透视变换
  7. 发票图像增强:让字迹更清晰
    • 7.1 方案一:全局二值化 + 形态学去噪
    • 7.2 方案二:非局部均值去噪 + 自适应阈值
  8. 特征检测基础:角点检测与 SIFT
    • 8.1 Harris 角点检测
    • 8.2 SIFT 特征提取

1. 引言

在计算机视觉领域,文档图像的自动处理始终是一个热门方向。无论是发票报销、证件识别还是表单数字化,第一步往往都需要从复杂的背景中提取出目标区域,并将其矫正为正面视图,再进行文字增强或信息提取。本文将通过一段完整的 Python + OpenCV 代码,带领读者从零开始实现一个完整的发票图像处理流水线,并在此基础上探讨图像增强、角点检测与特征提取技术。

文章涉及的代码覆盖了图像读取、缩放、灰度化、二值化、轮廓检测、多边形近似、透视变换、形态学去噪、Harris 角点检测、SIFT 特征提取等多个经典计算机视觉模块。我们将逐行分析代码的功能,并深入讲解背后涉及的算法原理,力求让每一位读者都能理解代码的每一处细节。

本文所有的代码均为完整可运行版本,读者只需修改 fapiao.jpgjiaotang.jpglinaiya.jpg 等图片路径即可复现实验。请放心,代码内容不会作任何修改,所有的解释与分析都是基于所提供的原始代码进行的。

2. 项目背景与目标

项目背景

日常办公中,我们经常会用手机拍摄发票、收据等纸质文档。然而,由于拍摄角度倾斜、光照不均、背景杂乱等因素,得到的照片往往不适合直接用于 OCR(光学字符识别)或存档。因此,需要自动检测出文档的四个顶点,通过透视变换将其矫正成矩形正面图像,并对图像进行增强处理以提高文字清晰度。

本文实现的功能模块

  1. 发票检测与矫正:利用边缘特征找到最大矩形轮廓,并用透视变换将其拉平。
  2. 图像增强:为了提升 OCR 的识别率,对矫正后的发票进行二值化与去噪,使得文字与背景分离更彻底。
  3. 角点检测与特征提取:作为扩展,代码还演示了 Harris 角点检测和 SIFT 特征提取,用于理解图像局部特征在目标识别、图像拼接等方面的应用。

技术栈

  • Python 3.x
  • OpenCV (cv2)
  • NumPy

3. 环境准备与基础函数

在开始编写主要逻辑之前,代码定义了两个辅助函数:

python 复制代码
def cv_show(name, img):
    cv2.imshow(name, img)
    cv2.waitKey(0)

cv_show 用于显示图像并等待用户按键。它使得在调试过程中可以方便地查看每一步处理后的结果。

python 复制代码
def resize(image, width=None, height=None, inter=cv2.INTER_AREA):
    (h, w) = image.shape[:2]
    if width is None and height is None:
        return image
    if width is None:
        r = height / float(h)
        dim = (int(w * r), height)
    else:
        r = width / float(w)
        dim = (width, int(h * r))
    resized = cv2.resize(image, dim, interpolation=inter)
    return resized

resize 函数实现了等比例缩放。如果只传入 width,则根据宽度计算比例;如果只传入 height,则根据高度计算比例。缩放时使用了 cv2.INTER_AREA 插值方式,适合缩小图像,可以避免摩尔纹。

4. 发票图像的预处理与轮廓检测

4.1 图像缩放

python 复制代码
image = cv2.imread('fapiao.jpg')
cv_show('image', image)

ratio = image.shape[0] / 500.0
orig = image.copy()
image = resize(orig, height=500)
cv_show('1', image)

首先读入发票图片。由于手机拍摄的照片分辨率可能很大,直接处理会影响速度,这里将图像的高度缩放为 500 像素,同时保存缩放比例 ratio,便于后续将坐标映射回原图尺寸。

orig 是原始图像的副本,用于最终的透视变换,因为此时我们得到的发票轮廓坐标是在缩小后的图像上计算的,需要乘以 ratio 才能用于原图。

4.2 灰度化与二值化

python 复制代码
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
edged = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU)[1]

将缩放后的图像转为灰度图。然后使用 OTSU 算法自动寻找最佳阈值进行二值化。cv2.threshold 的返回值包含两个元素,第一个是实际使用的阈值,第二个是二值化结果,我们用 [1] 取出结果。

OTSU 方法是一种全局自动阈值选择算法,它假设图像由前景和背景两部分组成,通过最大化类间方差来确定最优阈值。对于文档场景,如果光照均匀,OTSU 效果非常理想;但如果光照不均,则需要后续的自适应阈值处理。

4.3 轮廓查找与绘制

python 复制代码
cnts = cv2.findContours(edged.copy(), cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)[-2]
image_contours = cv2.drawContours(image.copy(), cnts, -1, (0, 0, 255), 1)
cv_show('image_contours', image_contours)

cv2.findContours 用于在二值图中查找轮廓。参数 cv2.RETR_LIST 表示检索所有轮廓而不建立层次关系;cv2.CHAIN_APPROX_SIMPLE 表示压缩水平、垂直和对角线段,只保留它们的端点,从而减少内存占用。

在 OpenCV 的不同版本中,findContours 的返回值数量不同(2个或3个),这里使用 [-2] 的方式可以兼容不同版本,取出轮廓列表。

cv2.drawContours 将检测到的所有轮廓用红色绘制在图像副本上,方便可视化。

5. 发票区域提取:最大轮廓与多边形近似

python 复制代码
screenCnt = sorted(cnts, key=cv2.contourArea, reverse=True)[0]
peri = cv2.arcLength(screenCnt, True)
screenCnt = cv2.approxPolyDP(screenCnt, 0.05 * peri, True)

通过 cv2.contourArea 计算每个轮廓的面积,并按面积降序排序,取出面积最大的轮廓。通常发票或文档将是图像中最大的四边形对象。

接下来计算该轮廓的周长 peri,然后使用 Douglas-Peucker 算法 进行多边形逼近:cv2.approxPolyDP,其中第二个参数 epsilon 设置为周长的 5%,这个值可以根据实际情况调整。该算法用较少的顶点来近似曲线,如果轮廓是四边形,逼近后应该得到 4 个点,代表发票的四个角。

python 复制代码
image_contour = cv2.drawContours(image.copy(), [screenCnt], -1, (0, 255, 0), 2)
cv2.imshow("image_contour", image_contour)
cv2.waitKey(0)

用绿色绘制出逼近后的发票轮廓,确认检测是否正确。

6. 透视变换:将发票摆正

获得发票的四个顶点后,下一步就是将其变换到一个矩形区域。为此,代码定义了 order_pointsfour_point_transform 两个函数。

6.1 坐标点排序

python 复制代码
def order_points(pts):
    rect = np.zeros((4, 2), dtype="float32")
    s = pts.sum(axis=1)
    rect[0] = pts[np.argmin(s)]
    rect[2] = pts[np.argmax(s)]
    diff = np.diff(pts, axis=1)
    rect[1] = pts[np.argmin(diff)]
    rect[3] = pts[np.argmax(diff)]
    return rect

输入的四个点可能是任意顺序的(例如左上、右上、右下、左下),但我们需要固定的顺序:左上、右上、右下、左下(顺时针)。排序的逻辑很巧妙:

  • 计算每个点的 x+y 之和,和最小的点为左上角,和最大的点为右下角。
  • 计算每个点的 y-x(即 diff),差值最小的点为右上角(因为 y 小 x 大),差值最大的点为左下角。

通过这种方法,无论顶点顺序如何,都能得到一致的排序。

6.2 变换后的尺寸计算

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

用欧几里得距离分别计算上下两条边的宽度和左右两条边的高度,并取最大值作为目标矩形的宽和高。这样能保证变换后的图像包含完整的发票内容,不会因为倾斜而裁剪。

6.3 透视变换矩阵与变换

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

dst 定义了变换后的四个顶点:左上角为原点,右上为 (maxWidth-1, 0) 等。cv2.getPerspectiveTransform 根据四对点计算 3×3 的透视变换矩阵 Mcv2.warpPerspective 将原图按照矩阵 M 进行透射变换,输出尺寸为 (maxWidth, maxHeight)

注意,这里传入的 image 是原始图像 orig,而 pts 则是从缩小图像上得到的顶点乘以 ratio 恢复至原图尺寸的结果。

python 复制代码
warped = four_point_transform(orig, screenCnt.reshape(4, 2) * ratio)
cv2.imwrite('invoice_new.jpg', warped)
cv2.imshow("xx", warped)

至此,我们得到了一张摆正后的发票图片,并保存为 invoice_new.jpg

7. 发票图像增强:让字迹更清晰

透视变换后的图片可能仍然存在光照不均、背景噪点等问题,直接用于 OCR 效果不佳。因此代码提供了两种增强方案。

7.1 方案一:全局二值化 + 形态学去噪

python 复制代码
gray_warped = cv2.cvtColor(warped, cv2.COLOR_BGR2GRAY)
blurred = cv2.GaussianBlur(gray_warped, (3, 3), 0)
_, thresh = cv2.threshold(blurred, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)

首先转灰度图,然后进行高斯模糊,去除细小噪点。这里使用 THRESH_BINARY_INV 得到黑底白字的二值图(因为后续形态学操作在白底黑字上不方便处理)。

python 复制代码
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (2, 2))
cleaned = cv2.morphologyEx(thresh, cv2.MORPH_CLOSE, kernel, iterations=1)
cleaned = cv2.morphologyEx(cleaned, cv2.MORPH_OPEN, kernel, iterations=1)
cleaned = cv2.bitwise_not(cleaned)

cv2.getStructuringElement 定义了 2×2 的矩形结构元素。闭运算(CLOSE)先膨胀后腐蚀,可以填充文字内部的小孔洞,连接断裂笔画;开运算(OPEN)先腐蚀后膨胀,能够去除背景中的孤立噪点。最后用 bitwise_not 反转回白底黑字,符合常见文档的显示习惯。

7.2 方案二:非局部均值去噪 + 自适应区域处理

方案二更为精细:

python 复制代码
denoised = cv2.fastNlMeansDenoising(gray_warped, h=10, templateWindowSize=7, searchWindowSize=21)

fastNlMeansDenoising 是非局部均值去噪算法,它通过在整个图像中搜索相似块来估计像素的真实值,在保留边缘的同时有效去除噪声。参数 h 控制滤波强度,模板窗口和搜索窗口决定了算法复杂度。

python 复制代码
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
enhanced = clahe.apply(denoised)

CLAHE(对比度受限自适应直方图均衡化)能够在局部区域增强对比度,同时限制噪声放大。这对于改善阴影部分的文字可读性尤其有用。

python 复制代码
_, thresh_global = cv2.threshold(enhanced, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)

这里采用了全局 OTSU 二值化,但结合 CLAHE 后,整体效果往往优于直接使用原图进行 OTSU。需要注意,如果光照极不均匀,全局阈值仍可能丢失细节,此时可改用自适应阈值,但在该方案中选择了闭运算补全文字,开运算去噪,兼顾全局与局部特征。

python 复制代码
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (2, 2))
cleaned = cv2.morphologyEx(thresh_global, cv2.MORPH_CLOSE, kernel, iterations=2)
cleaned = cv2.morphologyEx(cleaned, cv2.MORPH_OPEN, kernel, iterations=1)
cleaned = cv2.bitwise_not(cleaned)

闭运算迭代两次,进一步连接可能断裂的文字笔画;开运算一次去除背景小噪点。最终得到清晰的增强图像。

两种方案分别保存为 invoice_enhanced.jpginvoice_enhanced2.jpg,读者可以根据实际效果选择。

8. 特征检测基础:角点检测与 SIFT

代码的后半部分转向了另一个主题:图像特征。虽然与前文的发票处理没有直接关联,但它们是计算机视觉中不可分割的基础知识。

8.1 Harris 角点检测

python 复制代码
img = cv2.imread('jiaotang.jpg')
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
dst = cv2.cornerHarris(gray, blockSize=4, ksize=3, k=0.04)
img[dst > 0.01 * dst.max()] = [0, 0, 255]

Harris 角点检测的基本思想是:使用一个窗口在图像上移动,观察窗口内像素灰度的变化。如果窗口在各个方向移动时灰度都有较大变化,就认为是角点。数学上通过计算梯度协方差矩阵的特征值来判断。

参数说明:

  • blockSize:邻域大小;
  • ksize:Sobel 算子的窗口大小;
  • k:常数,通常取值 0.04~0.06。

dst 中数值越大,该点成为角点的可能性就越大。我们设定阈值 0.01 * dst.max() 来标记角点,并用红色标出。

8.2 SIFT 特征提取

python 复制代码
man = cv2.imread('linaiya.jpg')
man_gray = cv2.cvtColor(man, cv2.COLOR_BGR2GRAY)
sift = cv2.SIFT_create()
kp = sift.detect(man_gray)

SIFT(尺度不变特征变换)是目前应用广泛的特征提取算法,具有旋转、尺度、亮度不变性。cv2.SIFT_create() 创建检测器,detect 方法返回关键点列表,每个关键点包含坐标、尺度、方向等信息。

python 复制代码
man_sift = cv2.drawKeypoints(man, kp, outImage=None,
                             flags=cv2.DRAW_MATCHES_FLAGS_DRAW_RICH_KEYPOINTS)
cv2.imshow('man_sift', man_sift)

drawKeypoints 将特征点绘制在图像上,DRAW_RICH_KEYPOINTS 会显示尺度与方向的圆形标志,便于可视化。

python 复制代码
kp, des = sift.compute(man_gray, kp)
print(np.array(kp).shape, des.shape)

sift.compute 计算每个关键点的描述符(通常为 128 维向量),用于后续的特征匹配。描述符的维度、关键点的数量都打印了出来。

SIFT 是专利算法,但在 OpenCV 中已集成,使用时需注意版本和许可问题。