目录
[(1)cv_show 图像显示函数](#(1)cv_show 图像显示函数)
[(2)resize 等比例缩放函数](#(2)resize 等比例缩放函数)
[(3)order_points 四点坐标排序](#(3)order_points 四点坐标排序)
[(4)four_point_transform 透视变换核心](#(4)four_point_transform 透视变换核心)
[5、二值化 + 腐蚀去噪 + 角度修正](#5、二值化 + 腐蚀去噪 + 角度修正)
[3、OTSU 二值化优势](#3、OTSU 二值化优势)
一、项目前言
日常拍摄发票、证件、纸质文档时,难免出现倾斜、透视畸变、角度偏移问题,直接影响 OCR 文字识别、特征提取效果。传统裁剪、旋转无法解决近大远小的透视变形问题。
本文基于 OpenCV + Numpy 实现一套通用文档透视矫正完整流水线: 图像缩放预处理 → 轮廓检测 → 最大票据轮廓筛选 → 四边形拟合 → 四点透视变换转正 → 灰度二值化 → 腐蚀去噪 → 角度矫正
代码通用性极强,可适配发票、身份证、答题卡、纸质单据、指纹纸张等所有四边形平面文档矫正场景。
二、整体算法流程
原始倾斜图片 → 等比例压缩加速运算 → 灰度二值化 → 全局轮廓检测 → 筛选最大外框轮廓 → 多边形拟合四边形 → 四点坐标排序 → 透视变换拉直转正 → 高清原图还原矫正 → 灰度二值化降噪 → 形态学腐蚀去噪 → 角度旋转归一化 → 最终标准清晰文档图
最终达到下面的效果

三、完整可运行源码
python
import numpy as np
import cv2
# ===================== 工具函数1:图像显示封装 =====================
def cv_show(name, img):
cv2.imshow(name, img)
cv2.waitKey(0)
# ===================== 工具函数2:等比例缩放封装 =====================
def resize(image, width=None, height=None, inter=cv2.INTER_AREA):
# 获取图像高、宽,image.shape返回(高度,宽度,通道数),取前两位h,w
(h, w) = image.shape[:2]
# 宽高都不传,直接返回原图,无需缩放
if width is None and height is None:
return image
# 只指定高度,计算缩放比例r
if height is not None:
r = height / float(h)
# 宽度=原图宽度*缩放比例,高度为传入值
dim = (int(w * r), height)
# 只指定宽度,计算缩放比例r
else:
r = width / float(w)
# 高度=原图高度*缩放比例,宽度为传入值
dim = (width, int(h * r))
# 执行缩放,inter为插值方式,缩小图用INTER_AREA抗锯齿
resized = cv2.resize(image, dim, interpolation=inter)
return resized
# ===================== 工具函数3:四点坐标标准化排序 =====================
def order_points(pts):
# 创建4行2列float32数组,存储排序后的4个角点 [左上,右上,右下,左下]
rect = np.zeros(shape=(4, 2), dtype="float32")
# pts每行是(x,y),axis=1按行求和 s = x + y
s = pts.sum(axis=1)
# x+y最小:左上角(x小y小)
rect[0] = pts[np.argmin(s)]
# x+y最大:右下角(x大y大)
rect[2] = pts[np.argmax(s)]
# np.diff按行做差 diff = y - x
diff = np.diff(pts, axis=1)
# y-x最小:右上角(x大y小)
rect[1] = pts[np.argmin(diff)]
# y-x最大:左下角(x小y大)
rect[3] = pts[np.argmax(diff)]
return rect
# ===================== 工具函数4:四点透视变换核心 =====================
def four_point_transform(image, pts):
# 先对输入乱序四点标准化排序
rect = order_points(pts)
# 解包四个规范点:tl左上 tr右上 br右下 bl左下
(tl, tr, br, bl) = rect
# 计算底边br-bl的欧式距离(宽度A)
widthA = np.sqrt(((br[0] - bl[0]) ** 2) + ((br[1] - bl[1]) ** 2))
# 计算顶边tr-tl的欧式距离(宽度B)
widthB = np.sqrt(((tr[0] - tl[0]) ** 2) + ((tr[1] - tl[1]) ** 2))
# 取最大宽度作为矫正后画布宽度,防止内容裁切
maxWidth = max(int(widthA), int(widthB))
# 计算右侧tr-br高度A
heightA = np.sqrt(((tr[0] - br[0]) ** 2) + ((tl[1] - br[1]) ** 2))
# 计算左侧tl-bl高度B
heightB = np.sqrt(((tl[0] - bl[0]) ** 2) + ((tl[1] - bl[1]) ** 2))
# 取最大高度作为矫正后画布高度
maxHeight = max(int(heightA), int(heightB))
# 定义矫正后标准矩形的四个目标坐标
dst = np.array(object=[
[0, 0], # 左上对应画布原点
[maxWidth - 1, 0], # 右上对应画布右上角
[maxWidth - 1, maxHeight - 1], # 右下对应画布右下角
[0, maxHeight - 1] # 左下对应画布左下角
], dtype="float32")
# 计算3*3透视变换矩阵M:源四边形→目标标准矩形
M = cv2.getPerspectiveTransform(rect, dst)
# 根据变换矩阵映射原图像素,输出矫正后图像
warped = cv2.warpPerspective(image, M, dsize=(maxWidth, maxHeight))
return warped
# ===================== 主程序入口 =====================
if __name__ == "__main__":
# 1.读取原始票据图片,默认BGR三通道
image = cv2.imread('fapiao.jpg')
cv_show('原图image', image)
# 2.原图备份,后续在高清原图上做透视矫正
orig = image.copy()
# 计算缩放比例:原图高度 / 500,用于后续坐标还原
ratio = image.shape[0] / 500.0
# 将原图等比例缩小至高度500,轮廓检测提速
image = resize(orig, height=500)
cv_show('缩小预处理图', image)
# -------------------------- STEP1:灰度+OTSU二值化,分离前景背景 --------------------------
print("STEP 1: 灰度转换+二值化")
# BGR彩色图转为单通道灰度图,轮廓、阈值运算只能基于灰度图
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
# OTSU自适应二值化:自动计算全局分割阈值
# THRESH_BINARY:大于阈值变白(255),小于阈值变黑(0)
# 返回元组(最优阈值, 二值图像),[1]取出黑白图edged
edged = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU)[1]
# -------------------------- STEP2:查找图像全部轮廓 --------------------------
print("STEP 2: 查找全部轮廓")
# findContours参数:输入二值图、轮廓检索模式、轮廓压缩算法
# RETR_LIST:提取所有轮廓,不建立层级关系;CHAIN_APPROX_SIMPLE:压缩轮廓点,减少冗余坐标
# 返回值[0]图像、[1]轮廓列表、[2]层级,[-2]固定取出轮廓列表cnts
cnts = cv2.findContours(edged.copy(), cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)[-2]
# 在缩小图副本上绘制所有轮廓,颜色红色(0,0,255),线条粗细1
image_contours = cv2.drawContours(image.copy(), cnts, -1, color=(0, 0, 255), thickness=1)
cv_show(name='全部轮廓图', img=image_contours)
# -------------------------- STEP3:筛选面积最大轮廓(票据外框) --------------------------
print("STEP 3: 筛选最大外框轮廓")
# sorted排序,key=cv2.contourArea按轮廓面积排序,reverse=True降序,取第0个最大轮廓
screenCnt = sorted(cnts, key=cv2.contourArea, reverse=True)[0]
# 打印原始轮廓形状:(点数,1,2),每个点嵌套一层数组
print("多边形拟合前轮廓shape:", screenCnt.shape)
# 计算轮廓闭合周长,closed=True代表轮廓闭合
peri = cv2.arcLength(screenCnt, closed=True)
# 多边形近似拟合:将不规则轮廓简化为最少顶点多边形
# 0.05*peri为拟合精度,值越大顶点越少,票据外框会拟合出4个顶点
screenCnt = cv2.approxPolyDP(screenCnt, 0.05 * peri, closed=True)
print("多边形拟合后轮廓shape:", screenCnt.shape)
# 绘制筛选后的票据四边形轮廓,绿色线条,粗细2
image_contour = cv2.drawContours(image.copy(), [screenCnt], -1, (0, 255, 0), 2)
cv2.imshow("目标票据四边形轮廓", image_contour)
cv2.waitKey(0)
# -------------------------- STEP4:高清原图透视矫正 --------------------------
print("STEP 4:执行四点透视变换矫正")
# screenCnt原始shape(4,1,2),reshape转为(4,2)标准四点格式;*ratio把缩小图坐标还原为原图真实像素
warped = four_point_transform(orig, screenCnt.reshape(4, 2) * ratio)
# 保存矫正完成的彩色票据图片
cv2.imwrite('invoice_new.jpg', warped)
# 创建可拉伸窗口,避免大图超出屏幕
cv2.namedWindow('透视矫正彩色结果', cv2.WINDOW_NORMAL)
cv2.imshow("透视矫正彩色结果", warped)
cv2.waitKey(0)
# -------------------------- STEP5:矫正后图像灰度、二值化标准化 --------------------------
print("STEP 5:二值化、腐蚀去噪、尺寸归一化、旋转")
# 矫正后的彩色图转灰度图
warped = cv2.cvtColor(warped, cv2.COLOR_BGR2GRAY)
# 再次OTSU二值化,得到纯净黑白文档
ref = cv2.threshold(warped, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU)[1]
cv_show('二值化黑白票据', ref)
# 统一图像宽度为900像素,高度自动等比例适配,标准化尺寸
ref = resize(ref, width=900)
# 创建2*2全1形态学卷积核,用于轻度腐蚀
kernel = np.ones((2, 2), np.uint8)
# 腐蚀运算,迭代1次:收缩白色文字,消除细小白色噪点、纸张灰尘白点
ref_new = cv2.erode(ref, kernel, iterations=1)
# 图像逆时针旋转90度,修正拍摄倒置的文档方向
rotated_image = cv2.rotate(ref_new, cv2.ROTATE_90_COUNTERCLOCKWISE)
cv2.namedWindow('最终预处理成品', cv2.WINDOW_NORMAL)
cv2.imshow('最终预处理成品', rotated_image)
cv2.waitKey(0)
# 释放全部窗口
cv2.destroyAllWindows()
四、代码逐模块详细解析
1、工具封装函数
1.1 cv_show 图像显示函数
python
def cv_show(name, img):
cv2.imshow(name, img)
cv2.waitKey(0)
封装 OpenCV 窗口展示逻辑,无需重复写 waitKey,简化代码,适合调试。
1.2 resize 等比例缩放函数
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 height is not 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
固定宽 / 高自动等比例缩放,不拉伸图像。核心作用:大图压缩后轮廓检测速度更快,降低算力消耗,同时记录缩放比例,后续还原原图精准坐标。
1.3 order_points 四点坐标排序
python
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
轮廓检测得到的四点坐标是乱序的,透视变换必须固定顺序:左上、右上、右下、左下。
- x+y 求和:最小值为左上,最大值为右下
- y-x 求差:最小值为右上,最大值为左下
解决坐标混乱导致的透视扭曲、矫正失败问题。
1.4 four_point_transform 透视变换核心
python
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) + ((tl[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(object=[
[0, 0],
[maxWidth - 1, 0],
[maxWidth - 1, maxHeight - 1],
[0, maxHeight - 1]], dtype="float32")
# 图像透视变换 cv2.getPerspectiveTransform(src, dst[, solveMethod]) → MP获得转换之间的关系
# src:变换前图像四边形顶点坐标
# dst:变换后图像四边形顶点坐标
# cv2.warpPerspective(src, MP, dsize[, dst[, flags[, borderMode[, borderValue]]]]) → dst
# 参数说明:
# src:原图
# MP:透视变换矩阵,3行3列
# dsize:输出图像的大小,二元元组(width, height)
M = cv2.getPerspectiveTransform(rect, dst)
warped = cv2.warpPerspective(image, M, dsize=(maxWidth, maxHeight))
# 返回变换后结果
return warped
- 第一步标准化四点顺序,解包四个角点;
- 欧式距离公式计算四边形上下两条边、左右两条边的像素长度;
- 取最大宽、最大高作为输出画布尺寸:防止矫正后文档左右 / 上下被裁切;
dst:矫正后标准矩形的四个目标坐标,映射到画布四个角落;cv2.getPerspectiveTransform(src, dst):输入源四边形坐标、目标矩形坐标,求解 3×3 透视变换矩阵 M,矩阵存储像素映射规则;cv2.warpPerspective(image, M, dsize):使用矩阵 M 遍历原图所有像素,重映射到标准矩形画布,彻底消除近大远小透视畸变。
2、主程序分步详解
2.1 图像读取与缩放预处理
python
image = cv2.imread('fapiao.jpg')
cv_show('原图image', image)
orig = image.copy()
ratio = image.shape[0] / 500.0
image = resize(orig, height=500)
cv_show('缩小预处理图', image)
cv2.imread:读取图片,通道顺序 BGR(和 RGB 相反);orig = image.copy():深拷贝原图,后面必须在高清原图矫正,缩小图只用来找轮廓;ratio = 原图高度 / 500:缩放比例,后续轮廓坐标 × ratio,还原原图真实像素位置;- 缩小至高度 500:轮廓检测计算量大幅降低,运行速度提升数倍。
这里原图太大就不展示了,展示一下缩小之后的图片:

2.2 灰度转换 + OTSU 自适应二值化
python
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
edged = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU)[1]
COLOR_BGR2GRAY:三通道彩色转为单通道灰度图,轮廓、阈值、形态学操作仅支持单通道;cv2.threshold参数拆解:- 第 1 参数:输入灰度图;
- 第 2 参数 0:OTSU 模式下阈值自动计算,该参数失效;
- 第 3 参数 255:超过阈值的像素赋值为纯白;
THRESH_BINARY:二值模式,大于阈值 = 255,小于阈值 = 0;THRESH_OTSU:全局自适应阈值,自动区分纸张背景和文字前景,光线不均匀图片也不用手动调参;
- 返回值
(最优阈值, 二值图),[1]取出黑白二值图edged。
2.3 全局轮廓查找与绘制
python
cnts = cv2.findContours(edged.copy(), cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)[-2]
image_contours = cv2.drawContours(image.copy(), cnts, -1, color=(0, 0, 255), thickness=1)
cv_show(name='全部轮廓图', img=image_contours)
这里是画出全部轮廓的图:

cv2.findContours:轮廓检测 API,输入必须是二值图;edged.copy():传入副本,防止修改原图;cv2.RETR_LIST:提取图像中所有轮廓,不区分内外层级;cv2.CHAIN_APPROX_SIMPLE:压缩轮廓冗余点,比如直线只保留首尾两个点,减少坐标数量;
- 返回值兼容新旧 OpenCV 版本:新版返回
(img, contours, hierarchy),旧版返回(contours, hierarchy),[-2]统一取出轮廓列表; cv2.drawContours绘制轮廓:- 第 2 参数:轮廓列表 cnts;
- 第 3 参数 - 1:绘制全部轮廓;
(0,0,255):OpenCV 颜色顺序 BGR,红色;- thickness=1:轮廓线条粗细。
2.4 筛选票据外框 + 四边形拟合
python
screenCnt = sorted(cnts, key=cv2.contourArea, reverse=True)[0]
peri = cv2.arcLength(screenCnt, closed=True)
screenCnt = cv2.approxPolyDP(screenCnt, 0.05 * peri, closed=True)
image_contour = cv2.drawContours(image.copy(), [screenCnt], -1, (0, 255, 0), 2)
cv2.imshow("目标票据四边形轮廓", image_contour)
cv2.waitKey(0)
筛选最大的轮廓:

sorted(cnts, key=cv2.contourArea, reverse=True):cv2.contourArea(轮廓):计算轮廓包围区域面积;- reverse=True 降序排列,最大的轮廓就是票据外框,取索引 0;
cv2.arcLength(screenCnt, closed=True):计算闭合轮廓的总周长;cv2.approxPolyDP(轮廓, 拟合精度, closed=True):多边形近似算法,简化轮廓顶点;- 拟合精度 = 0.05 * 周长:精度越大顶点越少,票据矩形轮廓会被简化为 4 个顶点;
- 输出 shape 变为
(4,1,2),代表 4 个角点,满足四点透视变换输入要求;
- 绘制绿色粗线四边形,直观看到定位到的票据边界。
2.5 高清原图透视矫正保存
python
warped = four_point_transform(orig, screenCnt.reshape(4, 2) * ratio)
cv2.imwrite('invoice_new.jpg', warped)
cv2.namedWindow('透视矫正彩色结果', cv2.WINDOW_NORMAL)
cv2.imshow("透视矫正彩色结果", warped)
cv2.waitKey(0)
矫正位置之后的图片:

screenCnt.reshape(4,2):原始轮廓(4,1,2),去掉中间冗余维度,转为标准 4 行 2 列坐标数组;* ratio:缩小图上检测到的坐标,乘以缩放比例还原原图真实像素,不乘会矫正裁切、错位;four_point_transform(orig, ...):传入未压缩高清原图,矫正后保留原图清晰度;cv2.imwrite:保存矫正完成的无倾斜票据图片;cv2.namedWindow(..., cv2.WINDOW_NORMAL):创建可拖动缩放窗口,高分辨率图片不会超出屏幕无法查看。
2.6 灰度、二值化、腐蚀去噪、标准化尺寸、旋转
python
warped = cv2.cvtColor(warped, cv2.COLOR_BGR2GRAY)
ref = cv2.threshold(warped, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU)[1]
cv_show('二值化黑白票据', ref)
ref = resize(ref, width=900)
kernel = np.ones((2, 2), np.uint8)
ref_new = cv2.erode(ref, kernel, iterations=1)
rotated_image = cv2.rotate(ref_new, cv2.ROTATE_90_COUNTERCLOCKWISE)
cv2.namedWindow('最终预处理成品', cv2.WINDOW_NORMAL)
cv2.imshow('最终预处理成品', rotated_image)
cv2.waitKey(0)
最后处理完成,逆时针旋转90°旋转之后得到结果图片:

- 矫正后彩色图转灰度,再次 OTSU 二值化,分离文字与背景,去除纸张底色;
resize(ref, width=900):统一所有票据图像宽度为 900 像素,消除拍摄距离带来的尺寸差异,适配后续 OCR、特征匹配;kernel=np.ones((2,2),np.uint8):2×2 全 1 形态学卷积核,轻度腐蚀力度;cv2.erode(ref, kernel, iterations=1)腐蚀运算原理: 核在图像滑动,只有核覆盖区域全白,中心像素才保留白色;存在黑色则中心变黑; 效果:文字轻微收缩,纸张灰尘、细小白色噪点完全消除,文字边缘毛刺消失;cv2.ROTATE_90_COUNTERCLOCKWISE:图像逆时针旋转 90°,修正竖拍倒置文档,转为正常阅读方向。
五、核心知识点总结
1、为什么要用透视变换而不是普通旋转?
普通旋转只能矫正角度倾斜 ,无法矫正近大远小、透视变形(手机斜着拍文档),而四点透视变换可以将任意不规则四边形,拉直成标准矩形。
2、为什么要缩放再还原坐标?
原图分辨率太大,直接轮廓检测速度慢; 缩小图检测轮廓 → 坐标 × ratio 还原原图位置 → 在原图上精准矫正,兼顾速度和精度。
3、OTSU 二值化优势
全自动阈值分割,不受光线、亮度影响,非常适合纸质文档、票据的前景背景分割。
4、腐蚀操作的作用
本文使用小核轻度腐蚀,只去噪、不毁文字:
- 去除纸张灰尘、小白噪点
- 消除文字边缘毛边
- 断开极轻微粘连,让文档更干净
六、适用场景
- 发票、收据、票据矫正
- 身份证、证件摆正
- 答题卡、试卷矫正
- 纸质指纹、纸质表格预处理
- OCR 识别前置图像归一化