
简介基于深度学习TensorFlow的银行卡号识别项目是一套面向深度学习初学者与金融图像处理开发者的完整实战方案。项目以银行卡为对象解决卡号定位与识别两个核心环节覆盖从数据预处理、模型训练到图形界面交互的全流程。压缩包共59个文件以20个Python脚本、18张效果示意图为核心辅以GIF动图演示、PyQt5界面文件、说明文档和依赖清单整体仅5.24MB。脚本中采用CRNNCNN-BLSTM-CTC实现卡号识别EAST模型负责卡号定位并通过PyQt5搭建可视化操作界面使用者只需按readme说明将预训练模型分别放入crnn/model与east/model目录在PyCharm中打开项目并执行demo.py即可启动识别程序资源附带的识别前后对比图、数据增强样例和运行录屏也有助于逐步理解算法效果与调参思路。目前已有1029人学习下载适合在Windows和TensorFlow环境下快速跑通一套银行卡识别系统。1. 为什么银行卡号识别要用TensorFlow而不是OpenCV模板匹配银行卡号识别这个需求最早我在支付客户那里是被当成“用OpenCV找一下数字轮廓”来做的。真实卡面一上来反光、浮雕、不同字体、透视形变模板匹配和轮廓提取瞬间就不够用了。基于深度学习TensorFlow做卡号识别核心思路是用卷积网络提取卡面字符特征再用序列模型输出一串数字。它解决的是从一张卡面图像到一串号码的映射问题适合输入绑定、财务单据OCR、移动端自动填写这类场景。如果你已经会用Python手里有GPU或云主机把TensorFlow的安装、数据准备、网络训练、模型导出走通一遍就能得到一个可以对接业务接口的识别服务。这篇就按我自己常做的方案从数据合成讲到推理部署。2. 数据从哪来用公开卡号生成器和图像合成拆掉隐私门槛做银行卡号识别遇到的第一个问题不是模型而是数据。真实卡面是隐私信息拿不到也存不起就算客户给了几万个样本也没法直接进公开训练流程。我一般不会去网上爬卡号而是用程序合成一套“看起来像真的但完全虚构”的训练集。合成数据的作用是先把字符的切分、字体、排列方式教给模型之后再拿少量真实样本来做微调。这样才能在不碰敏感数据的情况下把深度学习项目跑起来。2.1 用Luhn算法生成合法但不存在的卡号银行卡号不是随便写的。前6位是发卡行标识BIN最后一位是校验位整体要过Luhn算法。所以生成训练卡号的标准做法是先取一组不会指向真实卡段的测试BIN比如行业通用的622202、622848这种再随机补中间位最后用Luhn算法算出校验位。这样生成的卡号既能满足模型学到的数字组合规律又不会撞上真实数据。import random def luhn_checksum(card_no: str) - bool: digits [int(d) for d in card_no] odd_digits digits[-1::-2] even_digits digits[-2::-2] checksum sum(odd_digits) for d in even_digits: checksum sum(int(x) for x in str(d * 2)) return checksum % 10 0 def generate_card(bin_prefix: str, length: int 16) - str: body list(bin_prefix) [str(random.randint(0, 9)) for _ in range(length - len(bin_prefix) - 1)] body_str .join(body) for i in range(10): candidate body_str str(i) if luhn_checksum(candidate): return candidate return 这里luhn_checksum按从右往左的奇偶位置处理数字偶数位乘以2结果再按位相加最终校验和能被10整除才算合法。generate_card把随机生成的中间位和候选校验位组合起来返回第一个通过校验的号码。注意这里的BIN只是占位不要用某个真实银行的当前卡段去推真实卡号训练数据里只需要“卡号形态”正确。2.2 把数字渲染成卡面并进行数据增强拿到卡号之后要把它画到背景上。常见做法是用PIL绘制再叠加OpenCV或imgaug的几何变换。我会保留一个字体目录里面放几种等宽数字字体因为银行卡号的印刷体通常是等宽或近似等宽的。绘制时把卡号按4位一组分开间隔一个空格背景用深浅不同的渐变这样才能模拟不同卡面设计。from PIL import Image, ImageDraw, ImageFont import numpy as np import cv2 def render_card(card_no: str, font_path: str): img Image.new(RGB, (420, 264), (25, 35, 45)) draw ImageDraw.Draw(img) font ImageFont.truetype(font_path, 28) grouped .join([card_no[i:i4] for i in range(0, len(card_no), 4)]) draw.text((36, 110), grouped, fill(255, 255, 255), fontfont) arr cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR) rows, cols arr.shape[:2] pts1 np.float32([[0, 0], [cols, 0], [0, rows], [cols, rows]]) offset np.random.randint(-12, 12, size(4, 2)) pts2 pts1 offset m cv2.getPerspectiveTransform(pts1, pts2) warped cv2.warpPerspective(arr, m, (cols, rows)) noise np.random.randint(0, 5, warped.shape, dtypenp.uint8) return cv2.add(warped, noise)这段代码先形成一张420x264的卡面再用单应变换对四个角施加随机偏移等于模拟了手持拍摄的倾斜。np.random.randint(0, 5, ...)生成轻微噪声让模型不会只认纯色背景。透视偏移不宜太大否则卡号本身会严重变形训练阶段模型很难收敛偏移范围控制在10像素左右比较合适。实际项目里除了透视我还会随机调亮度、对比度、饱和度以及给卡面加一层渐变遮罩。常用增强参数可以按下表来设。参数推荐范围作用背景亮度30~90模拟深色/浅色卡面字体大小26~32适配不同排版密度透视偏移5~12像素模拟拍摄角度变化高斯噪声0~5像素提升抗噪能力亮度增益-30~30覆盖反光和暗光场景2.3 把图像和标签写进TFRecord训练数据准备好之后下一步是写入TensorFlow的标准数据格式TFRecord。虽然可以像普通CV项目那样直接从文件夹读图但银行卡号识别训练往往要跑几十个epochTFRecord的顺序读取性能更好也方便在分布式训练时统一分发。写入TFRecord时图像以原始字节存储标签直接存卡号字符串。import tensorflow as tf def _bytes_feature(value): return tf.train.Feature(bytes_listtf.train.BytesList(value[value])) def write_tfrecord(images, labels, path): with tf.io.TFRecordWriter(path) as writer: for img, label in zip(images, labels): feature { image_raw: _bytes_feature(img.tobytes()), label: _bytes_feature(label.encode()) } example tf.train.Example( featurestf.train.Features(featurefeature)) writer.write(example.SerializeToString())_bytes_feature是TF系列的标准包装方式图像数组必须用tobytes()序列化否则会报类型错误。字符串标签用encode()转成bytes。解析时再通过tf.io.decode_raw恢复成张量。TFRecord并不是唯一选择如果数据量很小也可以直接用tf.keras.utils.image_dataset_from_directory但只要你想在GPU上把训练吃满建议从这里开始就养成用TFRecord的习惯。3. 网络结构CNN RNN CTC让变长序列识别成为可能银行卡号识别本质上是一个轻量OCR任务。网络结构的选择决定了模型能不能把“看到数字”变成“输出数字串”。看到很多初学者会直接拿一个图像分类网络来套比如用ResNet把卡号图片分到某个类别——这显然不行因为卡号有16位、18位、19位组合空间巨大分类网络没法输出不定长结果。所以TensorFlow项目的常见做法是用CNN压缩图像高度并提取特征再用RNN建模序列关系最后用CTC损失对齐预测与真实文本。3.1 为什么不能用“图片分类”思路做卡号识别图像分类网络的输出是一个固定维度的one-hot向量类别数必须预先定死。银行卡号不是单一物体而是一串长度可变的字符且字符之间有排列关系。如果强行切分成单字符还需要额外的字符检测模块切分错误会直接传导到识别结果。CTC序列建模的思路是让网络输出一个按时间步排列的概率分布每个时间步对应特征图的一个横向位置再通过去重和去blank解码出最终字符序列。这样就不需要显式切分字符模型自己学会数字之间的依赖比如连续两个相同数字时该输出几个。3.2 TensorFlow中定义CRNN模型下面的代码是一个能跑通的最小CRNN模型。输入是固定尺寸的卡面图像经过两层卷积池化后把特征图的宽度部分保留为时间步高度方向压缩掉送入双向GRU。双向保证每个时间步能看到左右上下文对卡号这种有较强顺序感的数据很有用。模型输出层的神经元数量等于字符类别数加1最后一个类别作为CTC的blank字符。from tensorflow.keras import layers, Model def build_crnn(input_shape(64, 240, 3), num_classes11): inputs layers.Input(shapeinput_shape, nameimage) x layers.Conv2D(32, 3, paddingsame, activationrelu)(inputs) x layers.MaxPooling2D((2, 2))(x) x layers.Conv2D(64, 3, paddingsame, activationrelu)(x) x layers.MaxPooling2D((2, 2))(x) _, h, w, c x.shape x layers.Reshape((w, c * h))(x) x layers.Dense(64, activationrelu)(x) x layers.Bidirectional(layers.GRU(64, return_sequencesTrue))(x) x layers.Dense(num_classes, activationsoftmax, nameoutput)(x) return Model(inputs, x)这里卷积后特征图的高度是h input_shape[0] / 4宽度是w input_shape[1] / 4。Reshape((w, c * h))把高度维和通道维合并得到[batch, time_steps, features]的序列结构。time_steps就是后面CTC损失里的logit_length。如果输入是64x240那么卷积两次后就是16x60的时间步序列。双向GRU的隐藏单元不宜太大64到128足够卡号字符依赖关系并不复杂。num_classes这里要传入10个数字加1个blank即11。3.3 CTC损失函数和编译配置模型定义好之后编译阶段不能直接使用普通交叉熵因为模型输出的是每帧概率而标签是完整字符串。TensorFlow提供tf.nn.ctc_loss它能够穷举所有可能的对齐路径计算真实标签在这些路径上的概率总和。在Keras里一般要写一个自定义损失函数。def ctc_loss_func(y_true, y_pred): batch_len tf.shape(y_pred)[0] input_length tf.fill([batch_len], tf.shape(y_pred)[1]) label_length tf.reduce_sum(tf.cast(y_true ! -1, tf.int32), axis1) return tf.reduce_mean(tf.nn.ctc_loss( labelsy_true, logitsy_pred, label_lengthlabel_length, logit_lengthinput_length, blank_index10)) model.compile(optimizeradam, lossctc_loss_func)这里有一个容易踩的坑输入模型的y_true需要是整数序列且短标签要用-1填充到固定最大长度因为TensorFlow的Tensor要求对齐。label_length统计每一行非-1的个数作为真实标签长度。blank_index固定为num_classes - 1也就是10。训练时adam的默认学习率是0.001但OCR任务我通常从0.0005起步太大会让CTC loss直接变NaN。如果你的优化器换成SGD学习率要调到0.01左右并配合momentum但SGD收敛明显更慢效果不一定比adam好。4. 训练准备和推理部署把模型变成一个能用的接口模型定义完毕接下来要把TFRecord喂进去跑完训练再导出。这个阶段最容易出问题的倒不是网络结构而是数据读取和标签对齐。很多人在TensorFlow项目里调了一整天epoch和batch最后发现loss不降其实是标签填充错了或者输入图像尺寸和模型定义不一致。所以我建议先把数据管道单独调试好再开始训练。4.1 用tf.data读取TFRecord并控制epoch与batch训练脚本里数据管道一般写成解析函数加Dataset构造。解析时先把图像从字节串还原成uint8再把卡号字符串转成每个数字的int列表最后填充-1到最大长度16。这样和CTC损失才能对齐。AUTOTUNE tf.data.AUTOTUNE def parse_example(example_proto): feature_map { image_raw: tf.io.FixedLenFeature([], tf.string), label: tf.io.FixedLenFeature([], tf.string), } parsed tf.io.parse_single_example(example_proto, feature_map) image tf.io.decode_raw(parsed[image_raw], tf.uint8) image tf.reshape(image, (64, 240, 3)) image tf.cast(image, tf.float32) / 127.5 - 1.0 label_chars tf.strings.bytes_split(parsed[label]) label tf.strings.to_number(label_chars, out_typetf.int32) label tf.pad(label, [[0, 16 - tf.shape(label)[0]]], constant_values-1) return image, label dataset tf.data.TFRecordDataset(train.tfrecord) dataset dataset.map(parse_example, num_parallel_callsAUTOTUNE) dataset dataset.shuffle(2000).batch(32).repeat(30).prefetch(AUTOTUNE)parse_example里tf.strings.bytes_split会把bytes的标签拆成单个字符然后转成数字编码。比如字符串“622202”会变成[6, 2, 2, 2, 0, 2]。填充-1到长度16后长度为6的样本就变成[6, 2, 2, 2, 0, 2, -1, -1, ...]。batch设置为32repeat(30)表示跑30个epoch习惯上我不在repeat里写很大值而是用epochs参数控制因为后面要看验证集来决定是否early stopping。prefetch让GPU在训练当前batch的同时提前准备下一批数据这在数据读盘慢时常能带来20%以上的吞吐提升。训练时可以直接用model.fit(dataset, epochs30)但一定要把验证集也做成TFRecord单独验证。下面这个列表是训练中我最常调的参数和它们的作用。参数建议值调整方向学习率0.0005loss不降时降到0.0001batch size32梯度不稳时降到16epoch20~30过拟合前看验证loss决定图像高度32或64高度太小CRNN特征不足图像宽度240或320宽度保证时间步足够4.2 训练曲线异常时先看数据再看网络如果训练画出来的loss一直停在2.3附近不下降第一件事不是加网络层而是确认标签和blank_index是否匹配。CTC的blank_index是类别索引模型softmax输出的维度是11blank_index必须是10。如果把标签0当成blank模型就会一直懵。另一个常见问题是输入图像的字符太密导致网络没有足够的时间步来区分相邻数字这时可以把图像宽度从240加到320让卷积后的时间步变多。训练到epoch 15之后我会把学习率降到原来的五分之一再进行几轮精修这样能显著降低最终的字符错误率。4.3 推理时选择greedy还是beam search模型训练完成后推理阶段需要把每个时间步的概率分布解码成最终卡号。最轻量的方法是greedy也就是取每个时间步概率最大的字符然后去掉连续重复和blank。由于卡号长度一般不变greedy通常够用但遇到数字“00”或“11”这种连续字符时greedy容易把它们合并成一个。如果这部分准确率不够可以换成CTC beam search。def decode_greedy(pred): pred_ids tf.argmax(pred, axis-1).numpy() result [] prev -1 for p in pred_ids: if p ! prev and p ! 10: result.append(str(p)) prev p return .join(result) def decode_beam(pred): decoded, _ tf.nn.ctc_beam_search_decoder( tf.transpose(pred, (1, 0, 2)), sequence_length[tf.shape(pred)[1]] * tf.shape(pred)[0], beam_width10) return decoded[0]decode_greedy里prev用来记录上一个预测字符遇到连续重复直接跳过blank不输出。decode_beam调用TensorFlow内置的CTC beam search它的时延比greedy高一个数量级但适合真实卡面反光导致的局部识别失败。实测下来对于清晰卡面greedy和beam的差距很小所以我一般会保留两个解码器默认greedy当模型置信度低时再用beam search做二次验证。4.4 用SavedModel导出一个可以对外服务的接口训练结束后要部署成服务就不能再依赖训练脚本里的全局变量。TensorFlow的model.save会把网络结构和权重打包成SavedModel目录加载端不需要重新定义模型。这是对接Flask服务最稳的方式。model.save(card_ocr_model, save_formattf)Flask服务里加载模型接收图片文件流先做尺寸归一化再调用预测。需要确保推理时图片预处理和训练时完全一致包括resize顺序、三通道顺序、归一化范围。如果训练时用的是(height, width, 3)推理时不能随手转成灰度图丢进去。下面是一个最小可用的识别接口。from flask import Flask, request, jsonify import cv2 import numpy as np import tensorflow as tf app Flask(__name__) model tf.keras.models.load_model(card_ocr_model, compileFalse) app.route(/ocr, methods[POST]) def ocr(): file request.files[image] img cv2.imdecode(np.frombuffer(file.read(), np.uint8), cv2.IMREAD_COLOR) img cv2.resize(img, (240, 64)) img img[None].astype(np.float32) / 127.5 - 1.0 pred model.predict(img, verbose0)[0] return jsonify({card_no: decode_greedy(pred)}) if __name__ __main__: app.run(host0.0.0.0, port8080)注意这里的compileFalse避免加载时重新连接CTC损失函数。加载后predict返回的还是softmax概率可以直接接解码函数。这个接口只做了图像输入生产环境里还要加上卡号区域检测、异常图片过滤和日志记录。5. 把模型放进真实环境前先做这3个验证模型训练完不是看一眼loss低就完事。真实卡面上的反光、遮挡、字体差异很难完全靠合成数据提前覆盖。我在上线前通常会按三步做验证每一步都能直接卡住发布。第一步是算字符错误率而不是整卡准确率。整卡准确率只看16位全对不对过于严格生产环境更关心单字符错误率。写个简单函数把预测变成字符串再和真实卡号逐字符比对低于5%的单字符错误率再考虑发布。验证数据集至少保留10%合成样本和几十张真实脱敏样本。第二步是做一个“卡号区域”检测前处理。OCR模型直接对整张卡做识别会把卡面上的银行Logo、中英文文字也当成干扰更稳的做法是先检测卡号区域再送入识别网络。检测可以用传统形态学找数字组的凸包也可以用TensorFlow Object Detection API训练一个轻量的卡号区域检测器。如果不想引入额外模型至少要在预处理时按长宽比切出卡号下方的区域我一般保留整卡高度的下三分之一然后送到识别模型。第三步是量化导出。深度学习模型在服务器上用FP32没问题但边缘端或移动端需要转成TensorFlow Lite。量化后模型体积能缩到四分之一速度提升一倍左右精度损失通常不到1%。converter tf.lite.TFLiteConverter.from_saved_model(card_ocr_model) converter.optimizations [tf.lite.Optimize.DEFAULT] tflite_model converter.convert() open(card_ocr.tflite, wb).write(tflite_model)转换之后一定要在真实部署设备上跑一遍验证脚本因为不同硬件的算子支持差异会暴露出来。如果发现某个算子不支持回退到FP16量化或者在网络结构里去掉GRU换成纯CNN。卡号识别这类序列任务很多算子都有兼容版本提前做好验证就能避免上线当天手忙脚乱。本文还有配套的精品资源点击获取