OpenCV实战——透视变换与身份证号码模板识别

作者:clorinda日期:2026/8/22

一、前言》》》》》》》

在现实生活中,发票、身份证和纸张通常不是完全正对着摄像头拍摄的。由于拍摄角度不同,原本的矩形会变成梯形,文字也会发生倾斜。

如果直接对这种图片进行识别,效果往往不理想。因此,通常需要先完成透视变换,把图像矫正成正面视图,再进行二值化、轮廓检测和模板匹配。

本篇主要介绍:

1,透视变换的原理;

2,四个角点的排序;

3,发票图像矫正;

4,形态学闭运算;

5,使用模板匹配识别身份证号码。


二、透视变换的基本原理

透视变换可以把原图中的任意四边形转换成一个规整的矩形。

例如,一张倾斜的发票有四个角:

左上、右上、右下、左下

通过透视变换后,这四个点会被映射到:

1(0, 0)
2(width, 0)
3(width, height)
4(0, height)

这样,发票就会被“拉正”。

1,透视变换的基本流程为:读取图像

2,缩放图像

3,灰度化

4,二值化

5,查找轮廓

6,找到最大四边形

7,计算透视矩阵

8,得到矫正结果


三、调整图片尺寸

发票图片通常比较大,直接处理会占用较多内存,因此可以先进行缩小。

1def resize(
2    image,
3    width=None,
4    height=None,
5    inter=cv2.INTER_AREA
6):
7    dim = None
8    h, w = image.shape[:2]
9
10    if width is None and height is None:
11        return image
12
13    if width is None:
14        ratio = height / float(h)
15        dim = (
16            int(w * ratio),
17            height
18        )
19    else:
20        ratio = width / float(w)
21        dim = (
22            width,
23            int(h * ratio)
24        )
25
26    return cv2.resize(
27        image,
28        dim,
29        interpolation=inter
30    )

使用方法:

1image = cv2.imread(
2    "fapiao.jpg"
3)
4
5orig = image.copy()
6
7ratio = image.shape[0] / 500.0
8
9image_small = resize(
10    orig,
11    height=500
12)

这里的 ratio 用于记录原图和缩小图之间的比例。后面在原图上进行透视变换时,需要将缩小图中得到的坐标还原回原始尺寸。


四、灰度化和自动阈值

1gray = cv2.cvtColor(
2    image_small,
3    cv2.COLOR_BGR2GRAY
4)

然后使用 Otsu 自动阈值法:

1edged = cv2.threshold(
2    gray,
3    0,
4    255,
5    cv2.THRESH_BINARY | cv2.THRESH_OTSU
6)[1]

Otsu 方法会根据图像的灰度分布自动寻找阈值,不需要我们手动设置具体数值。

普通二值化的写法是:

1ret, binary = cv2.threshold(
2    gray,
3    120,
4    255,
5    cv2.THRESH_BINARY
6)

而 Otsu 的阈值参数设置为 0:

1cv2.threshold(
2    gray,
3    0,
4    255,
5    cv2.THRESH_BINARY | cv2.THRESH_OTSU
6)

OpenCV 会自动计算合适的阈值。


五、查找发票轮廓

1contours = cv2.findContours(
2    edged.copy(),
3    cv2.RETR_LIST,
4    cv2.CHAIN_APPROX_SIMPLE
5)[-2]

将所有轮廓画出来:

1image_contours = cv2.drawContours(
2    image_small.copy(),
3    contours,
4    -1,
5    (0, 0, 255),
6    1
7)
8
9cv2.imshow(
10    "contours",
11    image_contours
12)
13
14cv2.waitKey(0)

发票通常是图像中面积较大的矩形,所以可以按照面积排序:

1screen_cnt = sorted(
2    contours,
3    key=cv2.contourArea,
4    reverse=True
5)[0]

接着计算周长:

1peri = cv2.arcLength(
2    screen_cnt,
3    True
4)

对轮廓进行近似:

1screen_cnt = cv2.approxPolyDP(
2    screen_cnt,
3    0.05 * peri,
4    True
5)

如果最终轮廓有四个点,就可以认为它是发票的四个角:

print(screen_cnt.shape)


六、四个角点排序

透视变换要求四个角点按照固定顺序排列,所以需要编写排序函数:

1import numpy as np
2
3def order_points(points):
4    rect = np.zeros(
5        (4, 2),
6        dtype="float32"
7    )
8
9    total = points.sum(axis=1)
10
11    rect[0] = points[np.argmin(total)]
12    rect[2] = points[np.argmax(total)]
13
14    diff = np.diff(
15        points,
16        axis=1
17    )
18
19    rect[1] = points[np.argmin(diff)]
20    rect[3] = points[np.argmax(diff)]
21
22    return rect

排序原理:

0,横坐标加纵坐标最小的点是左上角;

2,横坐标加纵坐标最大的点是右下角;

1,纵坐标减横坐标最小的点是右上角;

3,纵坐标减横坐标最大的点是左下角。

如果角点顺序混乱,透视后的图片可能会出现倒置或旋转。


七、计算变换后尺寸

假设四个角点为:

tl, tr, br, bl = rect

计算上下两条边的长度:

1width_a = np.sqrt(
2    ((br[0] - bl[0]) ** 2) +
3    ((br[1] - bl[1]) ** 2)
4)
5
6width_b = np.sqrt(
7    ((tr[0] - tl[0]) ** 2) +
8    ((tr[1] - tl[1]) ** 2)
9)
10
11max_width = max(
12    int(width_a),
13    int(width_b)
14)

计算左右两条边的长度:

1height_a = np.sqrt(
2    ((tr[0] - br[0]) ** 2) +
3    ((tr[1] - br[1]) ** 2)
4)
5
6height_b = np.sqrt(
7    ((tl[0] - bl[0]) ** 2) +
8    ((tl[1] - bl[1]) ** 2)
9)
10
11max_height = max(
12    int(height_a),
13    int(height_b)
14)

定义目标矩形的四个点:

1dst = np.array([
2    [0, 0],
3    [max_width - 1, 0],
4    [max_width - 1, max_height - 1],
5    [0, max_height - 1]
6], dtype="float32")

八、执行透视变换

获取透视变换矩阵:

1M = cv2.getPerspectiveTransform(
2    rect,
3    dst
4)

执行透视变换:

1warped = cv2.warpPerspective(
2    image,
3    M,
4    (max_width, max_height)
5)

完整函数如下:

1def four_point_transform(image, points):
2    rect = order_points(points)
3    tl, tr, br, bl = rect
4
5    width_a = np.sqrt(
6        ((br[0] - bl[0]) ** 2) +
7        ((br[1] - bl[1]) ** 2)
8    )
9
10    width_b = np.sqrt(
11        ((tr[0] - tl[0]) ** 2) +
12        ((tr[1] - tl[1]) ** 2)
13    )
14
15    max_width = max(
16        int(width_a),
17        int(width_b)
18    )
19
20    height_a = np.sqrt(
21        ((tr[0] - br[0]) ** 2) +
22        ((tr[1] - br[1]) ** 2)
23    )
24
25    height_b = np.sqrt(
26        ((tl[0] - bl[0]) ** 2) +
27        ((tl[1] - bl[1]) ** 2)
28    )
29
30    max_height = max(
31        int(height_a),
32        int(height_b)
33    )
34
35    dst = np.array([
36        [0, 0],
37        [max_width - 1, 0],
38        [max_width - 1, max_height - 1],
39        [0, max_height - 1]
40    ], dtype="float32")
41
42    matrix = cv2.getPerspectiveTransform(
43        rect,
44        dst
45    )
46
47    return cv2.warpPerspective(
48        image,
49        matrix,
50        (max_width, max_height)
51    )

调用:

1warped = four_point_transform(
2    orig,
3    screen_cnt.reshape(4, 2) * ratio
4)
5
6cv2.imwrite(
7    "invoice_new.jpg",
8    warped
9)

这里乘以 ratio 是因为轮廓是在缩小后的图片中找到的,而变换使用的是原图。


九、透视变换后的图像处理

透视矫正后,可以继续进行灰度化:

1warped_gray = cv2.cvtColor(
2    warped,
3    cv2.COLOR_BGR2GRAY
4)

再进行二值化:

1ref = cv2.threshold(
2    warped_gray,
3    0,
4    255,
5    cv2.THRESH_BINARY | cv2.THRESH_OTSU
6)[1]

为了让文字和图形更加连续,可以使用闭运算:

1kernel = np.ones(
2    (2, 2),
3    np.uint8
4)
5
6ref_new = cv2.morphologyEx(
7    ref,
8    cv2.MORPH_CLOSE,
9    kernel
10)

闭运算的过程是:

先膨胀,再腐蚀

主要作用包括:

1,填补小空洞;

2,连接断开的文字;

3,修复细小缺口;

4,使区域更加完整。

之后可以缩放并旋转图像:

1ref_new = resize(
2    ref_new,
3    width=500
4)
5
6rotated = cv2.rotate(
7    ref_new,
8    cv2.ROTATE_90_COUNTERCLOCKWISE
9)

十、模板匹配的基本思想

模板匹配是在一张大图中寻找与模板相似的区域。

1,身份证号码识别的思路是:准备0到9的数字模板

2,提取身份证图片中的数字区域

3,将每个数字缩放为统一大小

4,分别与0到9进行模板匹配

5,选择得分最高的数字

模板匹配的核心函数:

1result = cv2.matchTemplate(
2    roi,
3    digit_roi,
4    cv2.TM_CCOEFF
5)

roi 是待识别的数字,digit_roi 是某一个数字模板。

获取匹配分数:

1_, score, _, _ = cv2.minMaxLoc(
2    result
3)

将所有得分保存起来:

1scores = []
2
3for digit, digit_roi in digits.items():
4    result = cv2.matchTemplate(
5        roi,
6        digit_roi,
7        cv2.TM_CCOEFF
8    )
9
10    _, score, _, _ = cv2.minMaxLoc(
11        result
12    )
13
14    scores.append(score)

选择最高分:

1number = str(
2    np.argmax(scores)
3)

十一、提取数字模板

模板图中包含 0 到 9 的数字。首先读取并二值化:

1template_image = cv2.imread(
2    "TP.png"
3)
4
5gray = cv2.imread(
6    "TP.png",
7    0
8)
9
10binary = cv2.threshold(
11    gray,
12    150,
13    255,
14    cv2.THRESH_BINARY_INV
15)[1]

查找外部轮廓:

1contours = cv2.findContours(
2    binary,
3    cv2.RETR_EXTERNAL,
4    cv2.CHAIN_APPROX_SIMPLE
5)[-2]

将轮廓从左到右排序,确保顺序与数字一致。

提取每个数字区域:

1digits = {}
2
3for i, contour in enumerate(contours):
4    x, y, w, h = cv2.boundingRect(contour)
5
6    roi = binary[
7        y - 2:y + h + 2,
8        x - 2:x + w + 2
9    ]
10
11    roi = cv2.resize(
12        roi,
13        (57, 88)
14    )
15
16    roi = cv2.bitwise_not(roi)
17
18    digits[i] = roi

所有数字模板都被缩放为 57×88,这样才能进行统一比较。


十二、定位身份证号码

读取身份证图片:

1image = cv2.imread(
2    "card_id.jpg"
3)
4
5gray = cv2.imread(
6    "card_id.jpg",
7    0
8)

二值化:

1binary = cv2.threshold(
2    gray,
3    120,
4    255,
5    cv2.THRESH_BINARY_INV
6)[1]

查找数字轮廓:

1contours = cv2.findContours(
2    binary.copy(),
3    cv2.RETR_EXTERNAL,
4    cv2.CHAIN_APPROX_SIMPLE
5)[-2]

遍历轮廓并通过坐标筛选号码:

1locations = []
2
3for contour in contours:
4    x, y, w, h = cv2.boundingRect(contour)
5
6    if 330 < y < 360 and x > 220:
7        locations.append(
8            (x, y, w, h)
9        )

这里的筛选条件是根据当前图片中身份证号码的位置确定的。对于同一张或相似图片,这种方法比较简单有效。

然后按照横坐标排序:

1locations = sorted(
2    locations,
3    key=lambda item: item[0]
4)

这样可以保证数字按照从左到右的顺序识别。


十三、识别每个数字

1output = []
2
3for x, y, w, h in locations:
4    group = gray[
5        y - 2:y + h + 2,
6        x - 2:x + w + 2
7    ]
8
9    group = cv2.threshold(
10        group,
11        0,
12        255,
13        cv2.THRESH_BINARY | cv2.THRESH_OTSU
14    )[1]
15
16    roi = cv2.resize(
17        group,
18        (57, 88)
19    )
20
21    scores = []
22
23    for digit, digit_roi in digits.items():
24        result = cv2.matchTemplate(
25            roi,
26            digit_roi,
27            cv2.TM_CCOEFF
28        )
29
30        _, score, _, _ = cv2.minMaxLoc(
31            result
32        )
33
34        scores.append(score)
35
36    result_digit = str(
37        np.argmax(scores)
38    )
39
40    output.append(result_digit)

将结果拼接起来:

1card_number = "".join(output)
2
3print(
4    "Card ID #:",
5    card_number
6)

同时可以在图片上绘制识别结果:

1cv2.rectangle(
2    image,
3    (x - 5, y - 5),
4    (x + w + 5, y + h + 5),
5    (0, 0, 255),
6    1
7)
8
9cv2.putText(
10    image,
11    result_digit,
12    (x, y - 15),
13    cv2.FONT_HERSHEY_SIMPLEX,
14    0.65,
15    (0, 0, 255),
16    2
17)

十四、模板匹配的不足

模板匹配方法比较容易实现,但它对图片质量要求较高。

如果出现以下情况,识别效果可能下降:

身份证图片发生旋转;

数字大小发生变化;

数字字体不一致;

光照太暗或太亮;

数字区域被遮挡;

图片存在严重噪声;

号码位置发生改变。

此外,代码使用固定坐标筛选数字区域:

if 330 < y < 360 and x > 220:

这说明程序只适合当前尺寸和当前布局的图片。如果换一张身份证,号码位置可能不同,程序就无法识别。

更通用的做法是:

  1. 先进行透视变换;
  2. 统一图片大小;
  3. 根据相对比例定位号码区域;
  4. 使用 OCR 或深度学习模型识别。

总结

本篇主要学习了透视变换和模板匹配的实际应用。

透视变换可以将倾斜的发票、身份证和纸张矫正成正面图像。它的关键是找到目标区域的四个角点,并按照左上、右上、右下、左下的顺序排列,然后使用 getPerspectiveTransform()warpPerspective() 完成变换。

模板匹配可以通过比较图像相似度,在图片中找到目标区域。课程中使用数字模板识别身份证号码,主要步骤是:

1,准备数字模板

2,模板二值化

3,查找每个数字轮廓

4,定位身份证号码

5,统一数字大小

6,逐个匹配0到9

7,得到识别结果

通过本次练习可以发现,计算机视觉任务往往需要多个步骤配合完成。单纯使用模板匹配可能不够稳定,但如果结合透视矫正、灰度化、二值化和形态学处理,就可以完成一些简单的证件和票据识别任务。


OpenCV实战——透视变换与身份证号码模板识别》 是转载文章,点击查看原文


相关推荐


Rust Borrow借用详解:不转移所有权访问数据
程序员爱钓鱼2026/8/9

《Rust编程实战》系列第35篇 在上一篇文章中,我们学习了Rust的Copy,了解了哪些轻量数据类型可以在赋值或传参时自动复制。本篇继续进入Rust所有权体系中非常重要的概念: Borrow 中文通常称为“借用”。 前面我们已经知道,String、Vec等非Copy类型传递给另一个变量或函数时,通常会发生所有权移动: fn print_name(name: String) { println!("{}", name); } fn main() { let name = S


Rust图像处理第22节-从RGB到YCbCr: 让亮度和颜色分家
花褪残红青杏小2026/7/31

🦀 Rust + WASM 实战系列 第 22 篇 阅读时间:约 8 分钟 | 实战可运行 📌 写在前面 前面 21 篇,所有图像都是拿 R、G、B 三个数表示的。这一篇换一套坐标系:一个亮度 + 两个色度。 手机里的每一张照片、你看的每一个视频,存进文件之前都会先做这个转换。原因很简单:人眼对亮度极其敏感,对颜色极其迟钝,把两者分开之后,颜色那部分可以随便砍。 数学上,它就是一个 3×3 矩阵(19节的矩阵 × 向量)加它的逆矩阵(21节的 try_inverse) 🚀 TL;DR


🤯 面试被问 AI Workflow 和 Agent 有啥区别?3 张图 + 2 段代码讲清楚!
ReBound2026/7/23

🤯 面试被问 AI Workflow 和 Agent 有啥区别?3 张图 + 2 段代码讲清楚! 摘要:Workflow 和 Agent 到底有啥区别?为什么 Coze 工作流和 ReAct Agent 看起来都在"执行任务",本质却完全不同?本文从面试翻车经历出发,结合 Anthropic 官方定义、ReAct 框架、完整代码示例和实战选型指南,帮你彻底搞懂这对最容易混淆的 AI 概念。 📌 前言 上周面试,面试官笑眯眯地问了一句: "你说说 AI Workflow 和 Agent


大模型入门:从“猜词游戏“到“超级大脑“,一篇读懂 AI 大模型
修己xj2026/7/15

2023 年初,ChatGPT 横空出世,"大模型"三个字一夜之间刷爆了所有人的朋友圈。有人拿它写代码,有人拿它写情书,还有人拿它辅导孩子做数学——而且它居然真的会。 可当你真正想搞懂"大模型到底是什么"时,迎面而来的却是满屏的"Transformer""自注意力""千亿参数",瞬间劝退。 别慌。这篇文章,我们用打比方的方式,把大模型从里到外讲清楚。 一、什么是大模型?先搞懂"大"在哪 今天大家口中的大模型,通常特指大语言模型(LLM,Large Language Model)。ChatGPT


【Java实习面试算法冲刺】双指针
ZenithSourceQuest2026/7/6

第2类题型:双指针 为什么双指针题看起来不难,你一到面试就容易写乱 很多同学第一次刷双指针时,会觉得这类题比哈希表还“直观”。因为代码通常不长,变量也常常只有 left、right、slow、fast 四个名字。但真正到了面试现场,双指针反而很容易暴露出两类问题: 你会套模板,但说不清两个指针各自代表什么。你知道要移动某一边,却解释不出“为什么这样移动不会漏解”。你能把 三数之和 写个大概,却总在去重和边界上翻车。你把“会写代码”当成“理解题型”,结果一换题面就不稳。 如果你


论虚拟线程与 Kotlin IO 协程:资源开销、时长、高并发表现及适用场景与技术选型思考
zimoyin2026/6/28

在现代并发编程中,虚拟线程(由 Java 20+ 引入)和 Kotlin IO 协程(基于 Dispatchers.IO)是两种高效处理异步任务的技术框架。在资源开销、时长(特别是长时间 IO)、高并发场景表现、以及何时选择合适方案等方面各有特点。本文将逐一展开对比分析。 1. 资源开销对比 虚拟线程和协程的核心开销差异源于其底层设计原理: 维度虚拟线程Kotlin IO 协程关键差异内存开销固定栈机制:默认约 1MB1\text{MB}1MB 占⽤[注1]动态内存分配:2KB∼512KB2


当 AI 学会「自己催自己」:对 Loop Engineering 的理解与思考
莫西很trouble2026/6/19

先说一个让我「咯噔」一下的瞬间 前段时间看到 Claude Code 负责人 Boris Cherny 说了句话,大意是:我已经不写 Prompt 了,我只写 Loop 我的第一反应是:啊?Prompt 不是刚学会怎么写好吗?怎么就又过时了? 但仔细想了下这句话背后的意思,突然意识到它不是在讲什么新技术,而是在讲一个我们早就该意识到的问题 ——如果每次用 AI,你都要在它身边喊「继续」「还是报错」「你改了啥」「回滚」,那说明你其实不是在用工具,你是在当监工 而 Loop Engineer


限流:从单机QPS计数器到分布式三层防御体系
程序员小策2026/6/11

大家好,我是程序员小策。 先说一个反直觉的事实:加了限流之后,你系统的成功请求数量反而可能变多。 听起来很荒诞对吧?限流的字面意思就是"拦住一部分请求",拦住了怎么可能变多? 但数据不会骗人: 场景总请求数成功数成功率不限流5000000%加了限流50000500010% 不限流的时候,50000 个请求全部涌入数据库,连接池打满,超时重试又制造了一倍流量,雪崩导致所有接口全部失败——包括那些只想来浏览商品页的正常用户。 加了限流之后,50000 个请求里被拦掉了 45000 个,但这


Java学习笔记之泛型
飞翔网2026/6/4

前言 写 Java 代码时,你一定见过 List<String>、Map<Integer, String> 这种尖括号写法。这就是泛型(Generics)——Java 5 引入的最重要的语言特性之一。在没有泛型的时代,集合里塞什么都可以,取出来必须强制转型,稍不注意就 ClassCastException(类转型异常)。泛型的出现让类型安全从运行时提到了编译期。 但这只是泛型的冰山一角。泛型真正的难点在于类型擦除、通配符、PECS 原则——理解了这些,你才算真正掌握了泛型。 一、概念:什么是泛


【SpringBoot+Elasticsearch 内容搜索系统实战】:架构设计与全流程实现
fengxin_rou2026/5/29

🔥你好我是fengxin_rou这是我的个人主页fengxin_rou的主页 ❄️欢迎查看我的专栏我的专栏 《Java后端学习》、《JAVASE基础》、《JUC并发》、《redis》、《JVM虚拟机》、《MYSQL》、《黑马点评》、《rabbitmq》、《JavaWeb+AI的talis学习系统》、《苍穹外卖》 目录 前言 一、Elasticsearch 索引设计与初始化 1.1 核心概念类比 1.2 索引初始化实现 1.3 字段设计要点 二、搜索索引数据写入与同步机

首页编辑器站点地图

本站内容在 CC BY-SA 4.0 协议下发布

Copyright © 2026 聚合阅读