OpenCV综合实战:银行卡卡号识别
本篇博客将带你从零开始,使用 OpenCV 完成一个完整的银行卡卡号识别系统。通过模板匹配的思想,把图像中复杂的数字逐一识别出来,并输出卡号及卡类型。代码每一步都配有输出图片,便于直观理解。
一、项目思路与OpenCV知识点概览
1.1 项目背景
银行每天要处理大量信用卡,手动输入卡号既慢又容易出错。本项目的目标是:传入一张信用卡图片,程序自动识别出卡号并输出卡类型。
1.2 识别思路(核心思想:模板匹配)
整体思路可以概括为一句话:先准备好"标准答案"(模板),再让程序把图片中的每个数字和"标准答案"逐一比对,谁最像就是谁。
具体步骤如下:
| 步骤 | 操作 | 说明 |
|---|---|---|
| 1 | 准备模板 | 准备一张包含 0-9 数字的模板图(OCR-A 字体) |
| 2 | 模板处理 | 对模板灰度化、二值化、找轮廓,把 0-9 每个数字切成单独的小图 |
| 3 | 处理信用卡图 | 对信用卡图灰度化、顶帽操作、闭运算、二值化,凸显数字区域 |
| 4 | 定位卡号区域 | 通过轮廓筛选(宽高比、宽高范围)找到 4 组卡号区域 |
| 5 | 切分单数字 | 在每组区域内再找轮廓,得到单个数字 |
| 6 | 模板匹配 | 把每个数字和模板中的 0-9 比对,得分最高的即为识别结果 |
| 7 | 输出结果 | 拼接所有数字,根据首位数字判断卡类型 |
1.3 涉及的OpenCV知识点
本项目几乎覆盖了 OpenCV 图像处理的基础全流程,是一个非常好的综合练习:
- 图像基础操作:
cv2.imread、cv2.cvtColor、cv2.resize、cv2.imshow - 阈值处理:
cv2.threshold(普通阈值 + OTSU 自动阈值) - 形态学操作:
cv2.morphologyEx(顶帽、闭运算) - 轮廓检测:
cv2.findContours、cv2.drawContours、cv2.boundingRect - 模板匹配:
cv2.matchTemplate、cv2.minMaxLoc - 绘图:
cv2.rectangle、cv2.putText - 结构元素:
cv2.getStructuringElement
详细知识点说明可参考博主OpenCV基础文章:
Opencv基础:图像基础操作和图像平滑与降噪
OpenCV基础:图像形态学和边缘检测
OpenCV基础续:轮廓检测与模板匹配
二、前期准备:argparse 与自定义模块
在正式开始识别之前,我们先要理解两个辅助文件:args.py 和 myutils.py。它们分别演示了命令行参数解析和自定义工具模块的编写。
2.1 args.py:命令行参数解析
args.py 演示了 Python 标准库 argparse 的用法。它是 Python 内置的命令行参数解析工具,功能强大、用法简洁,是写脚本时的必备利器。
为什么要用 argparse?
在没有 argparse 之前,如果你想让脚本处理不同的输入图片,通常有两种笨办法:
- 每次打开代码,把文件路径手动改来改去 → 容易改错、效率低
- 使用
input()让用户运行时输入 → 无法写成自动化批处理脚本
有了 argparse 之后:可以通过命令行参数把输入传给程序,既灵活又不会修改代码。比如:
python 银行卡卡号识别.py -i card1.png -t kahao.png # 处理第1张卡
python 银行卡卡号识别.py -i card2.png -t kahao.png # 处理第2张卡,不用改代码
完整代码与逐行注释
# ===================== 第1行:导入模块 =====================
# 导入 argparse 模块,它是Python标准库中专门用来处理命令行参数的工具
# 类似我们用 pip install 某库,但 argparse 是Python自带的,不需要额外安装
import argparse
# ===================== 第2行:创建解析器 =====================
# 创建 ArgumentParser 对象(可以理解为一个"参数收集器")
# 后续所有 add_argument 都是在告诉这个收集器:"我有这样一个参数,请帮我盯着"
parser = argparse.ArgumentParser(
description="这是一个argparse用法演示脚本" # 可选:添加描述文字,用户用 -h 查看帮助时会显示
)
# ===================== 第3~5行:添加参数 =====================
# add_argument 用来注册"我要接收什么参数"
# 第一个参数 "-n" → 短选项(简称,命令行用 -n 张三 这样传)
# 第二个参数 "--name" → 长选项(全称,命令行用 --name 张三 这样传)
# type=str → 参数类型:解析后自动转换成字符串
# default="张三" → 默认值:用户不传这个参数时,程序默认使用"张三"
# help="名字" → 帮助说明:用户用 --help / -h 时会显示这条参数的用途
parser.add_argument("-n", "--name", type=str, default="张三", help="名字")
# 第二个参数:类型是 int,默认20
# 因为 type=int,所以传参时会自动把命令行的字符串"10"转成整数 10
# 如果用户传入一个非数字(比如 -a abc),argparse 会自动报错,不用我们自己写判断
parser.add_argument("-a", "--aaa", type=int, default=20, help="数值1")
# 第三个参数:类型是 int,默认90
parser.add_argument("-b", "--bbb", type=int, default=90, help="数值2")
# ===================== 第6行:解析参数 =====================
# parse_args() 会去"读取命令行输入",然后把所有参数的值打包成一个 Namespace 对象返回
# 比如用户执行: python args.py -n 李四 -a 10 -b 20
# 则 args 的值相当于: Namespace(name='李四', aaa=10, bbb=20)
args = parser.parse_args()
# ===================== 第7~9行:取值使用 =====================
# 用点语法取值:args.属性名
# 属性名就是长选项去掉 -- 之后的名字(例如 --name → args.name,--aaa → args.aaa)
n = args.name # 取"名字"参数的值
a = args.aaa # 取"数值1"参数的值
b = args.bbb # 取"数值2"参数的值
# ===================== 第10~11行:使用解析到的值 =====================
print(n) # 输出名字
print(a + b) # 输出两数之和
add_argument 常用参数速查表
| 参数名 | 作用 | 示例 |
|---|---|---|
| 第1、2个位置参数 | 短选项和长选项 | "-n", "--name" |
type | 声明参数的类型(自动转换) | type=int → 传10时自动变整数 |
default | 设置默认值(用户不传时用它) | default="张三" |
help | 帮助文字(-h 时显示) | help="输入你的名字" |
required=True | 设为必需参数,不传就报错 | required=True |
choices | 限制只能从给定列表中选值 | choices=["A","B","C"] |
action="store_true" | 开关型参数,加了就True,不加就False | action="store_true"(后面会用到的 -d 调试开关) |
运行示例
# ====== 示例1:不传参数,全部使用默认值 ======
python args.py
# 输出:
# 张三
# 110 → 20 + 90 = 110
# ====== 示例2:只传部分参数,其余用默认值 ======
python args.py -n 李四
# 输出:
# 李四
# 110 → 20(默认) + 90(默认)
# ====== 示例3:全部传参 ======
python args.py -n 王五 -a 10 -b 20
# 输出:
# 王五
# 30 → 10 + 20
# ====== 示例4:使用长选项传参 ======
python args.py --name 赵六 --aaa 50 --bbb 60
# 输出:
# 赵六
# 110 → 50 + 60
# ====== 示例5:查看帮助(-h 或 --help)======
python args.py -h
# 输出:
# usage: args.py [-h] [-n NAME] [-a AAA] [-b BBB]
#
# 这是一个argparse用法演示脚本
#
# options:
# -h, --help show this help message and exit
# -n NAME, --name NAME 名字
# -a AAA, --aaa AAA 数值1
# -b BBB, --bbb BBB 数值2
# ====== 示例6:传错类型会自动报错 ======
python args.py -a "我不是数字"
# 输出报错:
# argument -a/--aaa: invalid int value: '我不是数字'
# (argparse 自动为你做了类型校验,不用自己写 if 判断)
在银行卡识别中的应用
# 导入
import argparse
ap = argparse.ArgumentParser()
# -i 必须传(required=True)!没有输入图片怎么识别?
ap.add_argument("-i", "--image", required=True, help="path to input image")
# -t 必须传!没有模板图怎么匹配?
ap.add_argument("-t", "--template", required=True, help="path to template OCR-A image")
# vars() 把 Namespace 对象转成字典
# 原来:args.image → 现在:args["image"]
args = vars(ap.parse_args())
为什么用 vars() 转字典? 纯个人习惯。后续代码用 args["image"] 而不是 args.image 访问,两种写法效果一样,但字典格式方便后续调试时打印和遍历。
2.2 myutils.py:自定义工具模块
myutils.py 是一个自定义的 Python 模块(通俗说就是一个工具包文件),里面封装了两个在图像处理中反复用到的通用函数。主程序通过 import myutils 调用它们,避免把代码写得又长又乱。
为什么要封装成自定义模块?
想象一下:如果不封装,你在主程序里需要轮廓排序时要写一遍 zip/sorted/解包逻辑,卡号区域要排序时再写一遍,单字符排序又写一遍——相同的代码写三次,改的时候还要改三处,很容易忘改出bug。
封装后带来的4个好处:
| 好处 | 解释 |
|---|---|
| 代码复用 | sort_contours 在模板处理、卡号区域处理、单字符处理中都被调用,封装后写一次用三次 |
| 逻辑清晰 | 主程序只说"排序一下"就行,具体怎么排序的细节藏在 myutils 里,主程序就像读文章一样顺 |
| 便于维护 | 万一排序逻辑有bug或要加新功能,只改 myutils.py 里这一处,主程序不用动 |
| 可扩展性 | 以后做身份证识别、车牌识别等新项目时,直接 import myutils 就能用,不用再抄一遍 |
模块完整代码(带详细注解)
# 导入 OpenCV 库,因为两个函数都需要用到 cv2 的功能
import cv2
def sort_contours(cnts, method='left-to-right'):
"""
========== 函数1:轮廓排序 ==========
【功能】对一堆轮廓按指定方向排序,支持4种方向
【参数】
cnts: 轮廓列表(由 cv2.findContours 返回)
method: 排序方式,可选值:
'left-to-right' → 从左到右(默认),数字阅读顺序
'right-to-left' → 从右到左,阿拉伯文、希伯来文方向
'top-to-bottom' → 从上到下,阅读段落顺序
'bottom-to-top' → 从下到上,特殊场景
【返回值】
一个元组:(排好序的轮廓列表, 排好序的外接矩形列表)
【为什么要排序?】
findContours 找出来的轮廓顺序是"乱的",不保证是阅读顺序。
例如模板中"0 1 2 3 4 5 6 7 8 9",findContours 可能返回的顺序是"5 3 8 1 0 ..."
不排序就直接用,会导致 digits[0] 存的不是数字0,后面匹配就全错了。
"""
# ---------- 步骤1:确定排序规则 ----------
reverse = False # 默认升序(从小到大)
i = 0 # 默认按"x坐标"排序(i=0是x,i=1是y)
# 如果方向是"从右往左"或"从下往上",则需要倒序(从大到小)
if method == 'right-to-left' or method == 'bottom-to-top':
reverse = True
# 如果方向是"从上到下"或"从下到上",则排序依据变成"y坐标"(i=1)
if method == 'top-to-bottom' or method == 'bottom-to-top':
i = 1
# ---------- 步骤2:计算每个轮廓的外接矩形 ----------
# boundingRect(c) 返回 (x, y, w, h),其中:
# x = 矩形左上角x坐标,y = 矩形左上角y坐标
# w = 矩形宽度,h = 矩形高度
# 这里用列表推导式,对 cnts 中每个轮廓 c 都算一次矩形
boundingBoxes = [cv2.boundingRect(c) for c in cnts]
# ---------- 步骤3:同时对"轮廓"和"矩形"一起排序 ----------
# zip(cnts, boundingBoxes) → 把轮廓和它对应的矩形配对成一个个元组
# sorted(..., key=lambda b: b[1][i], reverse=reverse)
# key=lambda b: b[1][i] → 按矩形(b[1])的第i个值(即x或y坐标)排序
# reverse=reverse → 正序还是倒序
# zip(*排序结果) → *是"解包",把配对的元组重新拆成两个独立的列表
(cnts, boundingBoxes) = zip(*sorted(zip(cnts, boundingBoxes),
key=lambda b: b[1][i], reverse=reverse))
# ---------- 步骤4:返回排序结果 ----------
return cnts, boundingBoxes
def resize(image, width=None, height=None, inter=cv2.INTER_AREA):
"""
========== 函数2:等比例图像缩放 ==========
【功能】把图像缩放到指定宽度或高度,自动保持长宽比(不会拉伸变形)
【参数】
image: 输入图像(numpy数组)
width: 目标宽度,像素为单位。若为None则根据height计算
height: 目标高度,像素为单位。若为None则根据width计算
inter: 插值方法,默认 cv2.INTER_AREA(面积插值,最适合缩小图像)
【返回值】
缩放后的新图像
【为什么不直接用 cv2.resize?】
cv2.resize 要求你同时传 宽 和 高。比如原图是 640×403,
你想缩放到宽300,得自己算"300/640 * 403 ≈ 189"才能得到高189。
每次都手动算很麻烦还容易出错,所以封装成自动计算的函数。
"""
# ---------- 步骤1:获取原图尺寸 ----------
dim = None # dim 用来存放目标尺寸 (宽, 高)
(h, w) = image.shape[:2] # shape返回 (高, 宽, 通道数),[:2]只取高和宽
# ---------- 步骤2:特殊情况处理 ----------
# 如果既没指定宽也没指定高,直接返回原图不处理
if width is None and height is None:
return image
# ---------- 步骤3:两种缩放分支 ----------
if width is None:
# 【分支A】只指定了目标高度,没指定目标宽度
# r = 目标高度 / 原高度 → 算出缩放比例
# 新宽度 = 原宽度 * r → 保持长宽比
r = height / float(h)
dim = (int(w * r), height)
else:
# 【分支B】指定了目标宽度(没指定高度,或指定了也按宽度算)
# r = 目标宽度 / 原宽度 → 算出缩放比例
# 新高度 = 原高度 * r → 保持长宽比
r = width / float(w)
dim = (width, int(h * r))
# ---------- 步骤4:调用 cv2.resize 实际缩放 ----------
# interpolation 参数指定插值方法
# cv2.INTER_AREA → 面积插值,适合缩小(默认)
# cv2.INTER_LINEAR → 双线性插值,适合放大
resized = cv2.resize(image, dim, interpolation=inter)
return resized
# ============ 下面三行是代码中的注释示例(Python语法复习用)============
# for i,j in enumerate([5,6,7]): # enumerate: 遍历同时拿到索引和元素,i=0,j=5; i=1,j=6
# print(i,j)
#
# a=[i for i in range(5)] # 列表推导式:快速生成 [0,1,2,3,4]
# print(a)
#
# a=''.join(['4','0','0','0']) # ''.join: 把字符串列表用''连接起来 → '4000'
# print(a)
两个函数的调用示例(直观感受)
import cv2
import myutils
# ---- resize 示例 ----
img = cv2.imread("card1.png") # 假设原图 640×403
print(img.shape) # 输出: (403, 640, 3)
img2 = myutils.resize(img, width=300) # 只指定宽度
print(img2.shape) # 输出: (189, 300, 3) — 高度自动算出来了
img3 = myutils.resize(img, height=200) # 只指定高度
print(img3.shape) # 输出: (200, 318, 3) — 宽度自动算出来了
# ---- sort_contours 示例 ----
# 假设找到 3 个轮廓,它们的 x 坐标分别是 100, 20, 80(乱序)
# 排序后按 x 从小到大排列,返回的 cnts[0] 对应 x=20 那个轮廓
三、代码详细分析(每一步附输出图片)
下面按照程序执行顺序,逐步分析银行卡卡号识别的完整流程。每一步操作后面都配有实际输出的图片,配合理解会更直观。
3.1 导入库与参数设置
import numpy as np
import cv2
import argparse
import myutils
ap = argparse.ArgumentParser()
ap.add_argument("-i", "--image", required=True, help="path to input image")
ap.add_argument("-t", "--template", required=True, help="path to template OCR-A image")
args = vars(ap.parse_args())
说明:
required=True表示这两个参数必须提供,否则程序直接报错退出(防呆)vars(ap.parse_args())把 Namespace 对象转成字典,方便用args["image"]这种字典式访问
FIRST_NUMBER = {
"3": "American Express",
"4": "Visa",
"5": "MasterCard",
"6": "Discover Card"
}
说明:根据银行卡号首位数字判断卡类型(这是国际卡组织的标准约定):
- 3 开头 → 美国运通(American Express)
- 4 开头 → Visa(维萨)
- 5 开头 → MasterCard(万事达)
- 6 开头 → Discover Card(发现卡)
3.2 模板图像处理(准备"标准答案")
模板图是一张包含 0-9 十个数字的参考图,相当于考试的"标准答案"。我们先把每个数字从模板里切出来存好,后面识别时用来对比。
第①步:读取模板原图
img = cv2.imread(args["template"])
读取效果如下(文件 kahao.png):

这是一张白底黑字的 0-9 数字模板图,字体是 OCR-A(专门为光学字符识别设计的字体,每个数字形状差异明显,方便识别)。
第②步:灰度化
ref = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
灰度化后,图像从 3 通道彩色图变成 1 通道灰度图:

为什么灰度化?
- 彩色图包含 R、G、B 三个通道,
height × width × 3个数据 - 数字识别只关心形状,不关心颜色。转成单通道灰度图(只有亮度信息)后,数据量减少为 1/3,计算更快
- 后续的二值化、轮廓检测等操作都要求输入是灰度图或二值图
第③步:反二值化(黑底白字)
ref = cv2.threshold(ref, 10, 255, cv2.THRESH_BINARY_INV)[1]
反二值化后,图像变成纯黑白图,而且颜色反转了(白底黑字 → 黑底白字):

什么是反二值化?
THRESH_BINARY_INV的计算规则:- 像素值 大于阈值(10) → 变成 0(黑)
- 像素值 小于等于阈值(10) → 变成 255(白)
- 结果:原来的黑色数字(像素值≈0)变成白色,原来的白色背景(像素值≈255)变成黑色
为什么要反转颜色?
cv2.findContours()函数的工作原理是:在黑色背景中找白色的目标区域。如果不给数字变白,findContours会把整个白色背景当成一个大轮廓,而不是找里面的 10 个小数字。
第④步:找轮廓 + 画轮廓查看
refCnts = cv2.findContours(ref, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)[-2]
# 把找到的轮廓画在原图上(红色线条,粗度3)
ref_cnts_img = img.copy()
cv2.drawContours(ref_cnts_img, refCnts, -1, (0, 0, 255), 3)
轮廓被准确地找到了,每个数字都被红色边框包住:

关键参数说明:
| 参数 | 含义 | 为什么这样选? |
|---|---|---|
cv2.RETR_EXTERNAL | 只检测最外层轮廓 | 每个数字只需要外面那一圈轮廓,不需要检测数字内部的孔洞(比如 4、6、8 里面的洞) |
cv2.CHAIN_APPROX_SIMPLE | 只保留轮廓的关键点,压缩数据 | 比如矩形只存 4 个角点,不存所有 1000+ 个边界点,节省内存 |
[-2] | 取返回列表的倒数第二个元素 | OpenCV 3.x 返回 3 个值、4.x 返回 2 个值,[-2] 在两种版本下都是轮廓列表(通用兼容写法) |
第⑤步:轮廓从左到右排序
# 调用 myutils 里的轮廓排序函数,按 left-to-right(从左到右)排序
refCnts = myutils.sort_contours(refCnts, method="left-to-right")[0]
为什么一定要排序?
因为
findContours返回的轮廓顺序是"随机的"——可能先返回数字5,再返回2,再返回8……如果不排序,你没法知道索引0到底存的是数字几。排序后,轮廓按0→1→2→...→9的阅读顺序排列,这样后面用enumerate遍历时,i=0就恰好对应数字0的图像。
第⑥步:切割每个数字,缩放到统一大小,存入字典
digits = {} # 字典结构:key 是数字(0~9 的整数),value 是对应数字的二值图像
for (i, c) in enumerate(refCnts): # enumerate 同时拿到索引 i 和 轮廓 c
(x, y, w, h) = cv2.boundingRect(c) # 计算轮廓的外接矩形
roi = ref[y:y+h, x:x+w] # 用数组切片切出数字区域
roi = cv2.resize(roi, (57, 88)) # 缩放到 57×88 的标准大小
digits[i] = roi # 存入字典
切割出来的 0-9 每个数字如下所示(已统一缩放到 57×88,上方标注了对应数字):

为什么要统一缩放到 57×88?
模板匹配函数
matchTemplate有一个硬性要求:要对比的两张图尺寸必须完全一致。如果一个数字被切出来是 40×60,而另一个是 60×90,模板匹配就会报错。统一缩放到 57×88 后,后续所有对比操作都能正常进行。
最终 digits 字典的内容示意:
digits = {
0: 数字0的二值图像 (numpy数组, 57x88),
1: 数字1的二值图像 (numpy数组, 57x88),
...
9: 数字9的二值图像 (numpy数组, 57x88),
}
3.3 信用卡图像处理(寻找卡号区域)
模板准备好了,接下来处理待识别的信用卡图片。目标是:从整张大图中,找到 4 组卡号数字的位置并框出来。
第①步:读取信用卡原图
image = cv2.imread(args["image"])
原图如下(card1.png,一张模拟的 Visa 信用卡):

可以看到:卡号是 4000 1234 5678 9012,共 4 组 4 位,且卡片背景有纹理和渐变。
第②步:缩放到统一宽度 + 灰度化
image = myutils.resize(image, width=300) # 等比例缩放到宽 300 像素
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
缩放后尺寸变为 300×189,灰度化效果:
| 缩放后 | 灰度化 |
|---|---|
![]() | ![]() |
为什么缩放到 width=300?
后面筛选卡号区域时用到了硬阈值(如"宽度 40~55 像素")。如果每张图片尺寸都不一样,这些阈值就不能通用了。统一缩放到宽 300,所有尺寸阈值都能复用。
第③步:顶帽操作(消除背景,突出数字)
rectKernel = cv2.getStructuringElement(cv2.MORPH_RECT, (9, 3)) # 定义一个细长的矩形核
tophat = cv2.morphologyEx(gray, cv2.MORPH_TOPHAT, rectKernel) # 顶帽操作
顶帽操作的原理和效果非常关键,下面用一张三列对比图直观说明:

顶帽操作到底在做什么?
顶帽(TopHat) = 原始图像 − 开运算结果
其中 开运算(Open)= 先腐蚀,后膨胀
| 操作 | 作用 | 图中效果 |
|---|---|---|
| 腐蚀 + 膨胀(开运算) | 把"亮的小区域"腐蚀掉,再把剩下的区域膨胀恢复。小的亮点消失了,大面积背景被保留 | 中间列:数字没了,背景纹理还在 |
| 顶帽 = 原图 − 开运算 | 两相减后,大面积背景被减掉,剩下的就是原图中那些比周围更亮的小细节(即数字) | 右边列:背景消除了,只剩数字 |
为什么用 (9,3) 的细长核?
矩形核的尺寸决定了"腐蚀时多大的东西会被消除"。(9,3) 表示横向 9 像素、纵向 3 像素——专门用来匹配银行卡数字"宽矮"的形状,保证能把整个数字作为一个整体腐蚀掉,然后相减就只剩下数字了。
顶帽处理后的输出:

仔细观察:复杂的卡片背景(蓝色渐变、Logo、装饰线)几乎都消失了,只留下卡号数字和一些小噪声。顶帽操作是本项目能成功识别的关键预处理!
第④步:第一次闭运算(把同组 4 个数字粘成一个块)
closeX = cv2.morphologyEx(tophat, cv2.MORPH_CLOSE, rectKernel)
闭运算 = 先膨胀,后腐蚀
膨胀:把白色区域"长胖"一点,相邻的白色会连在一起
腐蚀:再"瘦身"回去,整体轮廓不变得更粗,但粘连的部分不会再分开
效果:同一组里靠得比较近的 4 个数字,被膨胀连接成了一个大的白色矩形块,方便后续按"块"来定位。
效果对比(三列):

第一次闭运算的输出(中间列):

观察:原来 4 个独立的数字,被合并成 4 个大的亮矩形区域。下面文字(如 VISA、VALID THRU)由于距离较远,没有被合并。
第⑤步:OTSU 自动二值化
thresh = cv2.threshold(closeX, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU)[1]
结果如下(可以看到数字区域是白色,但内部有些空洞):

什么是 OTSU 算法?
普通阈值二值化需要手动指定阈值(如 127),但不同图片亮度不同,手动阈值很难通用。
OTSU(大津法)会自动分析直方图,找到"前景与背景最佳的分界线",适合直方图有两个峰(双峰)的图像。
使用时第一个阈值参数填0,告诉算法"你自己算去吧"。
效果:把灰度图变成纯黑白图(数字白、背景黑),后面才能用 findContours 找轮廓。
第⑥步:第二次闭运算(方核,填充数字内部的空洞)
sqKernel = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5)) # 方形核
thresh = cv2.morphologyEx(thresh, cv2.MORPH_CLOSE, sqKernel)
结果如下(和 16 图对比,4 个数字内部的空洞明显被填平了):

为什么要用方形核再闭一次?
OTSU 二值化后,由于笔画粗细不均,有些数字(如 4、6、8、9)的内部可能出现黑色的空洞。用 5×5 的方核做一次闭运算,填充数字内部的小空洞,让 4 组数字都变成"结实"的矩形块,后续找轮廓时才能得到一个完整的矩形,而不是碎成好几块。
第⑦步:找所有轮廓 + 筛选卡号区域
# 找所有外轮廓
cnts = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)[-2]
# 遍历所有轮廓,按"宽高比+宽+高"筛选符合条件的
locs = []
for c in cnts:
(x, y, w, h) = cv2.boundingRect(c) # 获取轮廓外接矩形
ar = w / float(h) # 计算宽高比 aspect ratio
if 2.5 < ar < 4.0: # 第一关:宽高比要在 2.5 ~ 4.0 之间
if (40 < w < 55) and (10 < h < 20): # 第二关:宽 40~55,高 10~20
locs.append((x, y, w, h))
# 最后按 x 坐标从左到右排序,保证 4 组数字的阅读顺序
locs = sorted(locs, key=lambda x: x[0])
先看没筛选前的所有轮廓(红色框):

可以看到:除了 4 组卡号数字外,VISA 标志、日期框等都被找出来了,总共有十几个轮廓。不筛选就会把这些也当成数字。
经过宽高比和尺寸筛选后,只剩下 4 组正确的卡号区域(红色框 + G1~G4 编号):

筛选参数是怎么来的?
| 参数 | 为什么这个范围? |
|---|---|
2.5 < ar < 4.0(宽高比) | 一组有 4 个数字横排,每个数字大概是 1:1(高略大于宽),所以 4 个数字加起来的宽高比约为 3。 |
40 < w < 55(宽度) | 缩放后整图宽 300 像素,4 组数字均匀分布,每组宽度大约 40~50 像素。 |
10 < h < 20(高度) | 卡号数字高度大约是宽度的 1/3,约 12~18 像素。 |
这三个条件一卡,就把其他干扰项(如日期框、Logo、小装饰线)都过滤掉了。实际应用中,可以根据图片缩放后的大小适当调整阈值范围。
3.4 组内切分单数字 + 模板匹配(核心识别步骤)
经过上一步,我们锁定了 4 组卡号区域的位置。接下来,对每组区域再做一次"小范围的分割+识别",得到 4 个数字,最后拼接成 16 位完整卡号。
第①步:切出单组区域并局部二值化
output = [] # 最终卡号列表
for (gX, gY, gW, gH) in locs: # 遍历 4 组区域
groupOutput = [] # 本组的 4 个数字
# 切出区域:gY-5:gY+gH+5 表示上下多留 5 像素的安全边界,防止切到笔画
group = gray[gY-5 : gY+gH+5, gX-5 : gX+gW+5]
# 对局部区域单独做一次 OTSU 二值化(小区域内阈值更精准)
group = cv2.threshold(group, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU)[1]
以下是 4 组卡号每组的灰度图(左)和二值化图(右):
| 组号 | 灰度 vs 二值化对比 | 字符切分框 | 识别结果 |
|---|---|---|---|
| G1 | ![]() | ![]() | ![]() |
| G2 | ![]() | ![]() | ![]() |
| G3 | ![]() | ![]() | ![]() |
| G4 | ![]() | ![]() | ![]() |
两个问题的解释:
-
为什么要加 ±5 像素边界?
轮廓外接矩形的边缘通常紧贴数字笔画,如果直接按矩形坐标切,经常会把笔画的边缘切掉一点点(导致数字残缺,模板匹配会不准确)。多加 5 像素的安全边界,保证数字完整。 -
为什么要对局部区域重新二值化?
上一步的 OTSU 是在"整张大图"上算的阈值,可能对某个局部来说太亮或太暗。在小区域内重新做一次 OTSU,阈值更贴合局部的亮度分布,单字符的轮廓会更清晰。
第②步:在组内再找一次轮廓(找单字符)
digitCnts = cv2.findContours(group.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)[-2]
digitCnts = myutils.sort_contours(digitCnts, method="left-to-right")[0]
这一步就像在每组 4 个数字的范围内,再做一次"更小范围的找轮廓+排序",得到 4 个单字符的轮廓。上面表格中间列显示了切分效果:每个数字都被红色框准确框住。
第③步:模板匹配(核心!一个字符 vs 10 个模板逐个比对)
for c in digitCnts:
# 1. 切出单个字符
(x, y, w, h) = cv2.boundingRect(c)
roi = group[y:y+h, x:x+w]
# 2. 缩放到与模板一致的 57×88(非常关键!尺寸不一致会报错或匹配失败)
roi = cv2.resize(roi, (57, 88))
# 3. 和 0~9 每个模板做匹配,得到 10 个相似度分数
scores = []
for (digit, digitPOI) in digits.items():
# TM_CCOEFF = 相关系数法,值越大表示两张图越相似
result = cv2.matchTemplate(roi, digitPOI, cv2.TM_CCOEFF)
(_, score, _, _) = cv2.minMaxLoc(result) # 取相关系数的最大值作为得分
scores.append(score) # 存得分
# 4. 得分最高的那个模板数字,就是识别结果
# np.argmax(scores) 返回 scores 中最大值的索引(正好对应数字)
groupOutput.append(str(np.argmax(scores)))
模板匹配原理图解:
待识别字符 [4] vs 模板0 模板1 模板2 模板3 模板4
↓ ↓ ↓ ↓ ↓
得分:0.2 得分:0.1 得分:0.3 得分:0.5 得分:0.98 ←最高分
↑
识别结果: 4(因为模板4得分最高)
| 函数/方法 | 具体作用 |
|---|---|
cv2.matchTemplate(roi, digitPOI, cv2.TM_CCOEFF) | 把当前字符 roi 滑过模板 digitPOI,计算每个位置的相关系数。返回一个得分矩阵。 |
cv2.minMaxLoc(result) | 从得分矩阵中提取最大值和最小值。我们只取最大值(最匹配的分数)。 |
np.argmax(scores) | 返回得分列表中最大值的索引位置。由于模板是按 0,1,2,...,9 的顺序存的,索引 4 就表示匹配到了数字 4。 |
第④步:在原图上标注识别结果并输出
# 在原图上画红色矩形框
cv2.rectangle(image, (gX-5, gY-5), (gX+gW+5, gY+gH+5), (0, 0, 255), 1)
# 在矩形框上方写识别到的 4 位数字(红色,字号 0.65,粗度 2)
cv2.putText(image, "".join(groupOutput), (gX, gY-15),
cv2.FONT_HERSHEY_SIMPLEX, 0.65, (0, 0, 255), 2)
# 把本组 4 个数字追加到总结果列表中
output.extend(groupOutput)
# ===== 最终打印输出 =====
print("Credit Card Type:{}".format(FIRST_NUMBER[output[0]]))
print("Credit Card #:{}".format("".join(output)))
最终效果图(框出 4 组区域,上方标注识别到的数字):

控制台输出:
Credit Card Type:Visa
Credit Card #:4000123456789012
四、知识运用总结
本项目将多个 OpenCV 知识点串联起来,形成一个完整的识别系统。下表总结了各知识点的运用场景和作用:
| 知识点 | 运用场景 | 作用 |
|---|---|---|
灰度化 cvtColor | 模板图、信用卡图预处理 | 从 3 通道降为 1 通道,减少计算量 |
反二值化 threshold + BINARY_INV | 模板图处理 | 白底黑字 → 黑底白字,让 findContours 找到数字而非背景 |
OTSU二值化 threshold + THRESH_OTSU | 顶帽后图像、局部组区域 | 自动选最佳阈值,适应不同亮度的图 |
顶帽操作 MORPH_TOPHAT | 信用卡图预处理 | 整图最关键预处理:消除背景纹理,只剩数字亮细节 |
闭运算1 MORPH_CLOSE + 细长核 | 顶帽结果 | 横向膨胀,把同组 4 个数字粘成一个矩形块 |
闭运算2 MORPH_CLOSE + 方核 | OTSU二值化结果 | 填充数字内部空洞,让矩形块完整 |
结构元素 getStructuringElement | 所有形态学操作 | 定制核形状:(9,3) 细长用于横连,(5,5) 方用于填洞 |
轮廓检测 findContours | 模板、卡号组、单字符 | 三次调用,从粗到细逐级定位 |
外接矩形 boundingRect | 轮廓筛选、区域切割 | 获取轮廓的 x、y、w、h |
轮廓排序 sort_contours | 模板、组区域、单字符 | 确保阅读顺序 左→右 |
轮廓筛选 宽高比 + 宽 + 高 | 找卡号组区域 | 排除 Logo、日期、文字等干扰 |
模板匹配 matchTemplate + TM_CCOEFF | 识别每个字符 | 与 10 个模板比对,取最高相似度 |
绘图 rectangle / putText | 结果展示 | 在原图上标注框和识别数字 |
核心思想总结
模板匹配识别系统 = 准备标准答案 + 多尺度特征提取 + 逐位相似度比较
【准备标准答案】
模板图(kahao.png)
↓ 灰度化
↓ 反二值化(黑底白字)
↓ findContours 找 10 个轮廓
↓ sort_contours 排序(0→9)
↓ boundingRect 切出每个数字
↓ resize 统一 57×88
↓
得到 digits = {0: 0图, 1: 1图, ..., 9: 9图}
【提取待识别数字】
信用卡图(card*.png)
↓ resize 统一宽 300 像素
↓ 灰度化
↓ ★ 顶帽操作(消除背景的关键)
↓ 闭运算1(细长核)→ 4组数字粘连成4个块
↓ OTSU 二值化 → 黑白图
↓ 闭运算2(方核)→ 填充块内空洞
↓ findContours 找所有轮廓
↓ 宽高比+尺寸筛选 → 得到 4 组坐标
↓ 对每组切图 → 局部 OTSU 二值化
↓ findContours + sort → 得到 4×4 = 16 个单字符
↓
【逐位比较 + 输出】
对每个单字符:
↓ resize 到 57×88
↓ 与 digits[0..9] 分别 matchTemplate,得 10 个分
↓ argmax 取最高分对应的数字
↓
拼接 → 16 位卡号
↓
首位查 FIRST_NUMBER → 卡类型 Visa/MasterCard...
↓
画框+标文字 → 显示结果
五、课后作业:身份证号码识别
5.1 作业要求
仿照银行卡卡号识别的思路和代码框架,独立完成身份证号码识别系统:
- 输入:一张身份证图片 + 身份证数字模板图(可以自己制作或使用
sfzmb.png) - 输出:
- 识别到的身份证号码(应为 18 位)
- 校验合法性结果(校验码对不对)
- 从号码中提取的信息:出生日期、性别、地区代码
5.2 与银行卡识别的异同点提示
相同点(可以直接复用银行卡代码框架的部分)
| 方面 | 说明 |
|---|---|
| 整体流程 | 模板处理 → 图像预处理 → 区域定位 → 切分字符 → 模板匹配,完全一致 |
| 模板准备 | 灰度化 + 反二值化 + 找轮廓 + 切割 + resize + 字典存储,完全一致 |
| 模板匹配 | matchTemplate(TM_CCOEFF) + minMaxLoc + argmax,完全一致 |
| 轮廓排序 | myutils.sort_contours(method="left-to-right"),直接调用 |
| 等比例缩放 | myutils.resize(image, width=xxx),直接调用 |
| 顶帽+闭运算+OTSU 的预处理链条 | 思路完全一致,只有核的尺寸需要调整 |
一句话:myutils.py 两个函数全部可以原封不动地复用,主程序的整体结构也不用大改。
不同点(必须修改/新增的地方)
| 方面 | 银行卡 | 身份证 | 修改原因 |
|---|---|---|---|
| 号码位数&格式 | 16位,分 4组×4位,纯数字 | 18位,一整条连续,末位可能是 X | 身份证号码没有空格分组,而且末位校验码可能是罗马数字 X |
| 模板字符数 | 0-9 共 10 个字符 | 0-9 + X 共 11 个字符 | X 如果不在模板中,可以用数字 8 做兜底或单独处理逻辑 |
| 区域筛选阈值 | ar: 2.5~4.0w: 40~55h: 10~20 | ar: 7~20(更大)w: 150~350(更大)h: 15~45 | 18位数字连在一起,整个矩形比4位组长得多,宽高比和宽度大约是银行卡的 4~5 倍 |
| 形态学核尺寸 | rectKernel(9, 3)sqKernel(5, 5) | 更细长的核,如 (20~30, 3~5)sqKernel(7~9, 7~9) | 要把 18 位数字粘成一整条,核必须相应加长;身份证字符也更大,方核要稍大 |
| 字符分割策略 | 组内直接 findContours 即可(4位不怎么粘连) | 两阶段法: ① 大核粗定位整条区域 ② 在区域内用小核(3×3)精细分割 | 18 位连续数字用大核闭运算后容易全部粘成一块,必须用更小的核二次分割 |
| resize 宽度 | width=300 | 建议 width=500 | 身份证上的字更小,放大分辨率更有利于识别小字符 |
| 校验逻辑 | 首位查 FIRST_NUMBER 判断卡类型(查表) | 国家标准校验码算法(加权因子+模11+查表) | 身份证有严格的国家标准,校验不通过则号码非法(不是"类型"问题) |
| 信息提取 | 仅输出卡类型(1个字段) | 提取出生日期、性别、地区代码(3个字段) | 身份证号码的每一段都编码了个人信息 |
| 中文字符干扰 | 卡上基本是英文+数字,干扰少 | 姓名、性别、民族、住址、出生都是中文,形态学可能被误检 | 加"位置加分":身份证号码一定在卡片底部,靠下的区域给额外加分 |
身份证校验码算法(国家标准 GB 11643-1999)
身份证第 18 位是校验码,不是随机数字,是按前面 17 位计算出来的。算法代码如下:
# ===== 常量定义(国家标准固定值,不要改)=====
# 前17位各自对应的"加权因子"(第i位乘这个数)
WEIGHTING_FACTORS = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]
# 余数 → 校验码 查表映射(余数2对应X,因为罗马数字X代表10)
CHECK_CODE_MAP = {
0: '1', 1: '0', 2: 'X', 3: '9', 4: '8',
5: '7', 6: '6', 7: '5', 8: '4', 9: '3', 10: '2'
}
# ===== 校验函数 =====
def validate_id_card(id_number):
# 1. 基础格式校验:必须是 17位数字 + 1位(数字或X/x)
import re
if not re.match(r'^\d{17}[\dXx]$', id_number):
return False, "格式不合法"
# 2. 计算校验码
total = 0
for i in range(17):
total += int(id_number[i]) * WEIGHTING_FACTORS[i]
remainder = total % 11
expected = CHECK_CODE_MAP[remainder]
actual = id_number[17].upper() # 小写x统一转大写X
if expected == actual:
return True, "校验码正确,号码合法"
else:
return False, f"校验码错误,应为 {expected},实际是 {actual}"
身份证号码段含义 + 信息提取
身份证号 18 位 = 地区码(6位) + 出生日期码(8位) + 顺序码(3位) + 校验码(1位)
示例:4 3 0 5 1 2 1 9 8 9 0 8 1 3 1 3 6 7
└─地区码─┘ └────出生日期────┘ └顺序码┘ └校验码┘
| 位数段 | 含义 | 提取代码 | 示例值 |
|---|---|---|---|
| 第 1~6 位 | 行政区划代码(地区代码) | id_number[:6] | 430512 → 湖南省邵阳市北塔区 |
| 第 7~14 位 | 出生年月日(YYYYMMDD) | id_number[6:14] | 19890813 → 1989年08月13日 |
| 第 15~17 位 | 顺序码(同地区同生日的人排序) | id_number[14:17] | 第 17 位(性别位):奇数=男,偶数=女 |
| 第 18 位 | 校验码(0-9 或 X) | id_number[17] | 用于验证号码是否合法 |
信息提取代码:
def get_id_card_info(id_number):
area_code = id_number[:6] # 地区码
birth_str = id_number[6:14]
birth_date = f"{birth_str[:4]}年{birth_str[4:6]}月{birth_str[6:8]}日" # 出生日期
gender_code = int(id_number[16])
gender = "男" if gender_code % 2 == 1 else "女" # 性别
return {"地区代码": area_code, "出生日期": birth_date, "性别": gender}
5.3 作业完成步骤建议
- 第 1 步:先跑通
银行卡卡号识别.py,把 5 张 card1~card5.png 都试一遍,确认每一步的输出图片都理解 - 第 2 步:准备身份证模板图
sfzmb.png+ 身份证照片sfz.jpg,先独立写一个小脚本,确认模板能被正确切分 - 第 3 步:复制银行卡代码作为骨架,依次修改:
resize(width=500)把放大分辨率rectKernel和sqKernel的尺寸调大- 区域筛选条件放宽(ar 范围和 w、h 范围)
- 写
validate_id_card()校验函数 - 写
get_id_card_info()信息提取函数
- 第 4 步:如果整条粘连,加一个
extract_chars_from_region()函数,在区域内用 (3,3) 小核单独做二值化+找单字符(参考之前写好的身份证识别代码) - 第 5 步:如果有多个候选区域,加
score_region()评分选择逻辑(长度18位的 +1000 分,靠下的区域加分) - 第 6 步:验证识别结果,确认 18 位数字是否正确,校验码是否通过
写在最后:本项目用纯 OpenCV 实现了数字识别,核心是模板匹配。虽然精度不如深度学习模型,但胜在轻量、无需训练、易于理解,是学习 OpenCV 图像处理的绝佳综合案例。掌握了银行卡识别,再举一反三完成身份证识别,你就真正入门了传统计算机视觉的 OCR 基本流程!















4146

被折叠的 条评论
为什么被折叠?



