OpenCV 四点透视矫正票据完整实战:轮廓检测 + 透视变换 + 二值腐蚀去噪全流程详解

目录

一、项目前言

二、整体算法流程

三、完整可运行源码

四、代码逐模块详细解析

1、工具封装函数

[(1)cv_show 图像显示函数](#(1)cv_show 图像显示函数)

[(2)resize 等比例缩放函数](#(2)resize 等比例缩放函数)

[(3)order_points 四点坐标排序](#(3)order_points 四点坐标排序)

[(4)four_point_transform 透视变换核心](#(4)four_point_transform 透视变换核心)

2、图像预处理与轮廓检测

3、筛选票据外框轮廓

4、精准透视矫正

[5、二值化 + 腐蚀去噪 + 角度修正](#5、二值化 + 腐蚀去噪 + 角度修正)

五、核心知识点总结

1、为什么要用透视变换而不是普通旋转?

2、为什么要缩放再还原坐标?

[3、OTSU 二值化优势](#3、OTSU 二值化优势)

4、腐蚀操作的作用

六、适用场景


一、项目前言

日常拍摄发票、证件、纸质文档时,难免出现倾斜、透视畸变、角度偏移问题,直接影响 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
  1. 第一步标准化四点顺序,解包四个角点;
  2. 欧式距离公式计算四边形上下两条边、左右两条边的像素长度;
  3. 取最大宽、最大高作为输出画布尺寸:防止矫正后文档左右 / 上下被裁切;
  4. dst:矫正后标准矩形的四个目标坐标,映射到画布四个角落;
  5. cv2.getPerspectiveTransform(src, dst):输入源四边形坐标、目标矩形坐标,求解 3×3 透视变换矩阵 M,矩阵存储像素映射规则;
  6. 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)
  1. cv2.imread:读取图片,通道顺序 BGR(和 RGB 相反);
  2. orig = image.copy():深拷贝原图,后面必须在高清原图矫正,缩小图只用来找轮廓;
  3. ratio = 原图高度 / 500:缩放比例,后续轮廓坐标 × ratio,还原原图真实像素位置;
  4. 缩小至高度 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]
  1. COLOR_BGR2GRAY:三通道彩色转为单通道灰度图,轮廓、阈值、形态学操作仅支持单通道
  2. cv2.threshold参数拆解:
    • 第 1 参数:输入灰度图;
    • 第 2 参数 0:OTSU 模式下阈值自动计算,该参数失效;
    • 第 3 参数 255:超过阈值的像素赋值为纯白;
    • THRESH_BINARY:二值模式,大于阈值 = 255,小于阈值 = 0;
    • THRESH_OTSU:全局自适应阈值,自动区分纸张背景和文字前景,光线不均匀图片也不用手动调参;
  3. 返回值(最优阈值, 二值图)[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)

这里是画出全部轮廓的图:

  1. cv2.findContours:轮廓检测 API,输入必须是二值图;
    • edged.copy():传入副本,防止修改原图;
    • cv2.RETR_LIST:提取图像中所有轮廓,不区分内外层级;
    • cv2.CHAIN_APPROX_SIMPLE:压缩轮廓冗余点,比如直线只保留首尾两个点,减少坐标数量;
  2. 返回值兼容新旧 OpenCV 版本:新版返回(img, contours, hierarchy),旧版返回(contours, hierarchy)[-2]统一取出轮廓列表;
  3. 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)

筛选最大的轮廓:

  1. sorted(cnts, key=cv2.contourArea, reverse=True)
    • cv2.contourArea(轮廓):计算轮廓包围区域面积;
    • reverse=True 降序排列,最大的轮廓就是票据外框,取索引 0;
  2. cv2.arcLength(screenCnt, closed=True):计算闭合轮廓的总周长;
  3. cv2.approxPolyDP(轮廓, 拟合精度, closed=True):多边形近似算法,简化轮廓顶点;
    • 拟合精度 = 0.05 * 周长:精度越大顶点越少,票据矩形轮廓会被简化为 4 个顶点;
    • 输出 shape 变为(4,1,2),代表 4 个角点,满足四点透视变换输入要求;
  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)

矫正位置之后的图片:

  1. screenCnt.reshape(4,2):原始轮廓(4,1,2),去掉中间冗余维度,转为标准 4 行 2 列坐标数组;
  2. * ratio:缩小图上检测到的坐标,乘以缩放比例还原原图真实像素,不乘会矫正裁切、错位
  3. four_point_transform(orig, ...):传入未压缩高清原图,矫正后保留原图清晰度;
  4. cv2.imwrite:保存矫正完成的无倾斜票据图片;
  5. 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°旋转之后得到结果图片:

  1. 矫正后彩色图转灰度,再次 OTSU 二值化,分离文字与背景,去除纸张底色;
  2. resize(ref, width=900):统一所有票据图像宽度为 900 像素,消除拍摄距离带来的尺寸差异,适配后续 OCR、特征匹配;
  3. kernel=np.ones((2,2),np.uint8):2×2 全 1 形态学卷积核,轻度腐蚀力度;
  4. cv2.erode(ref, kernel, iterations=1)腐蚀运算原理: 核在图像滑动,只有核覆盖区域全白,中心像素才保留白色;存在黑色则中心变黑; 效果:文字轻微收缩,纸张灰尘、细小白色噪点完全消除,文字边缘毛刺消失;
  5. cv2.ROTATE_90_COUNTERCLOCKWISE:图像逆时针旋转 90°,修正竖拍倒置文档,转为正常阅读方向。

五、核心知识点总结

1、为什么要用透视变换而不是普通旋转?

普通旋转只能矫正角度倾斜 ,无法矫正近大远小、透视变形(手机斜着拍文档),而四点透视变换可以将任意不规则四边形,拉直成标准矩形。

2、为什么要缩放再还原坐标?

原图分辨率太大,直接轮廓检测速度慢; 缩小图检测轮廓 → 坐标 × ratio 还原原图位置 → 在原图上精准矫正,兼顾速度和精度。

3、OTSU 二值化优势

全自动阈值分割,不受光线、亮度影响,非常适合纸质文档、票据的前景背景分割。

4、腐蚀操作的作用

本文使用小核轻度腐蚀,只去噪、不毁文字

  • 去除纸张灰尘、小白噪点
  • 消除文字边缘毛边
  • 断开极轻微粘连,让文档更干净

六、适用场景

  • 发票、收据、票据矫正
  • 身份证、证件摆正
  • 答题卡、试卷矫正
  • 纸质指纹、纸质表格预处理
  • OCR 识别前置图像归一化