OpenCV综合实战:银行卡卡号识别

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.imreadcv2.cvtColorcv2.resizecv2.imshow
  • 阈值处理cv2.threshold(普通阈值 + OTSU 自动阈值)
  • 形态学操作cv2.morphologyEx(顶帽、闭运算)
  • 轮廓检测cv2.findContourscv2.drawContourscv2.boundingRect
  • 模板匹配cv2.matchTemplatecv2.minMaxLoc
  • 绘图cv2.rectanglecv2.putText
  • 结构元素cv2.getStructuringElement

详细知识点说明可参考博主OpenCV基础文章:
Opencv基础:图像基础操作和图像平滑与降噪
OpenCV基础:图像形态学和边缘检测
OpenCV基础续:轮廓检测与模板匹配

二、前期准备:argparse 与自定义模块

在正式开始识别之前,我们先要理解两个辅助文件:args.pymyutils.py。它们分别演示了命令行参数解析和自定义工具模块的编写。

2.1 args.py:命令行参数解析

args.py 演示了 Python 标准库 argparse 的用法。它是 Python 内置的命令行参数解析工具,功能强大、用法简洁,是写脚本时的必备利器。

为什么要用 argparse?

在没有 argparse 之前,如果你想让脚本处理不同的输入图片,通常有两种笨办法:

  1. 每次打开代码,把文件路径手动改来改去 → 容易改错、效率低
  2. 使用 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,不加就Falseaction="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在这里插入图片描述在这里插入图片描述在这里插入图片描述

两个问题的解释

  1. 为什么要加 ±5 像素边界?
    轮廓外接矩形的边缘通常紧贴数字笔画,如果直接按矩形坐标切,经常会把笔画的边缘切掉一点点(导致数字残缺,模板匹配会不准确)。多加 5 像素的安全边界,保证数字完整。

  2. 为什么要对局部区域重新二值化?
    上一步的 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
  • 输出:
    1. 识别到的身份证号码(应为 18 位)
    2. 校验合法性结果(校验码对不对)
    3. 从号码中提取的信息:出生日期、性别、地区代码

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.0
w: 40~55
h: 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. 第 1 步:先跑通 银行卡卡号识别.py,把 5 张 card1~card5.png 都试一遍,确认每一步的输出图片都理解
  2. 第 2 步:准备身份证模板图 sfzmb.png + 身份证照片 sfz.jpg,先独立写一个小脚本,确认模板能被正确切分
  3. 第 3 步:复制银行卡代码作为骨架,依次修改:
    • resize(width=500) 把放大分辨率
    • rectKernelsqKernel 的尺寸调大
    • 区域筛选条件放宽(ar 范围和 w、h 范围)
    • validate_id_card() 校验函数
    • get_id_card_info() 信息提取函数
  4. 第 4 步:如果整条粘连,加一个 extract_chars_from_region() 函数,在区域内用 (3,3) 小核单独做二值化+找单字符(参考之前写好的身份证识别代码)
  5. 第 5 步:如果有多个候选区域,加 score_region() 评分选择逻辑(长度18位的 +1000 分,靠下的区域加分)
  6. 第 6 步:验证识别结果,确认 18 位数字是否正确,校验码是否通过

写在最后:本项目用纯 OpenCV 实现了数字识别,核心是模板匹配。虽然精度不如深度学习模型,但胜在轻量、无需训练、易于理解,是学习 OpenCV 图像处理的绝佳综合案例。掌握了银行卡识别,再举一反三完成身份证识别,你就真正入门了传统计算机视觉的 OCR 基本流程!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值