目录
- 引言
- 项目背景与目标
- 环境准备与基础函数
- 发票图像的预处理与轮廓检测
- 4.1 图像缩放
- 4.2 灰度化与二值化
- 4.3 轮廓查找与绘制
- 发票区域提取:最大轮廓与多边形近似
- 透视变换:将发票摆正
- 6.1 坐标点排序
- 6.2 变换矩阵计算
- 6.3 透视变换
- 发票图像增强:让字迹更清晰
- 7.1 方案一:全局二值化 + 形态学去噪
- 7.2 方案二:非局部均值去噪 + 自适应阈值
- 特征检测基础:角点检测与 SIFT
- 8.1 Harris 角点检测
- 8.2 SIFT 特征提取
1. 引言
在计算机视觉领域,文档图像的自动处理始终是一个热门方向。无论是发票报销、证件识别还是表单数字化,第一步往往都需要从复杂的背景中提取出目标区域,并将其矫正为正面视图,再进行文字增强或信息提取。本文将通过一段完整的 Python + OpenCV 代码,带领读者从零开始实现一个完整的发票图像处理流水线,并在此基础上探讨图像增强、角点检测与特征提取技术。
文章涉及的代码覆盖了图像读取、缩放、灰度化、二值化、轮廓检测、多边形近似、透视变换、形态学去噪、Harris 角点检测、SIFT 特征提取等多个经典计算机视觉模块。我们将逐行分析代码的功能,并深入讲解背后涉及的算法原理,力求让每一位读者都能理解代码的每一处细节。
本文所有的代码均为完整可运行版本,读者只需修改 fapiao.jpg 与 jiaotang.jpg、linaiya.jpg 等图片路径即可复现实验。请放心,代码内容不会作任何修改,所有的解释与分析都是基于所提供的原始代码进行的。
2. 项目背景与目标
项目背景
日常办公中,我们经常会用手机拍摄发票、收据等纸质文档。然而,由于拍摄角度倾斜、光照不均、背景杂乱等因素,得到的照片往往不适合直接用于 OCR(光学字符识别)或存档。因此,需要自动检测出文档的四个顶点,通过透视变换将其矫正成矩形正面图像,并对图像进行增强处理以提高文字清晰度。
本文实现的功能模块
- 发票检测与矫正:利用边缘特征找到最大矩形轮廓,并用透视变换将其拉平。
- 图像增强:为了提升 OCR 的识别率,对矫正后的发票进行二值化与去噪,使得文字与背景分离更彻底。
- 角点检测与特征提取:作为扩展,代码还演示了 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_points 和 four_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 的透视变换矩阵 M。cv2.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.jpg 和 invoice_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 中已集成,使用时需注意版本和许可问题。