
简介基于OpenCV与TensorFlow的银行卡号识别完整项目包是一套从图像定位、字符分割到模型训练与号码识别的全流程方案面向计算机相关专业在校学生、教师及企业开发者既可用于毕业设计、课程设计、项目初期立项演示也适合希望从OpenCV图像处理进阶到TensorFlow深度学习识别的小白学习参考。包内共145个文件压缩后约2.98MB以118张png图片样本为主体搭配6个Python源码文件和3张jpg图片另含5套TensorFlow模型检查点meta、data-00000-of-00001、index、checkpoint以及md/txt说明文档覆盖训练、推理、说明各环节可直接恢复模型继续训练也能快速查看源码思路与配套文档。目前已有83人学习浏览项目完成度较高。项目源自答辩评审95分的个人高分作品代码经测试可稳定运行目录结构清晰便于二次开发读者既可作为完整方案直接使用也适合在基础上针对特定卡证场景调整识别逻辑是课设、毕设及项目起步阶段很好的参考资料。1. 把银行卡号识别拆成四段比整图直接进检测模型更稳银行卡号识别是一个典型的“看着简单、一跑全是问题”的 CV 项目。卡面有底纹、有反光卡号可能是平面印刷也可能是凸起压印光照一变二值化结果就差很远。把整张卡直接交给一个端到端检测模型去回归定位框会随着卡面花纹和反光抖动反而更不稳定。更常见的做法是拆成四段OpenCV 负责卡号区域定位和单字符分割TensorFlow 负责十类数字的分类。前者解决“数字在哪”后者解决“数字是什么”两个模块各管一段出问题能立刻定位到环节。整套流程从原始图片到输出 16 位卡号只需要一个轻量 CNN 和几十行图像处理代码纯 CPU 也能跑。对想入门 OpenCV 图像处理、同时补一下 TensorFlow 模型训练和部署的工程师来说这个项目把两套技术栈的衔接点都覆盖到了。后面的内容按定位、分割、训练、集成、调优五段展开每一段都把参数选择和常见坑位写清楚。2. 用 OpenCV 定位卡号区域阈值、形态学与轮廓筛选2.1 为什么定位交给 OpenCV而不是用目标检测模型“卡号在哪”这个问题在银行卡场景里有很强的结构先验。卡号是一行横向排列的数字宽高比通常在 4 到 15 之间位置在卡面中下方和卡面底纹存在明确的灰度差异。先验越强传统图像处理的性价比就越高。用 Otsu 二值化加形态学闭运算再加轮廓查找不依赖 GPU单帧耗时在 10 毫秒量级也不需要准备标注数据。通用目标检测模型更适合卡面任意角度、任意字体、卡片被折叠这类极端场景但多数业务照片是持卡人平放拍摄透视变形不大。先用 OpenCV 把卡号 ROI 从整张图里框出来再让模型只做它真正擅长的数字分类整个系统的稳定性和可调试性都更好。检测模型一旦把底纹或背景文字框进来后续分割的噪声会比预想的大得多。这一个取舍决定了整个项目后面的调试成本。2.2 预处理链路顺序与两个最容易调错的参数定位的处理顺序固定为灰度化、中值滤波、Otsu 二值化、形态学闭运算、轮廓查找、候选过滤。每一步的输出都是下一步的输入前一步参数错了后面很难排查。这个链路里最容易调错的是二值化极性和闭运算核尺寸。中值滤波核固定为 3 即可。卡号区域的噪声以细小的点状反光为主3×3 中值能去掉孤立亮点又不会把数字笔画磨平。高斯滤波在这个场景反而容易让边缘变钝。二值化优先用 Otsu 而不是固定阈值。同一套代码在室内灯光和窗外自然光下的亮度差异很大固定阈值只能照顾一种环境。Otsu 通过最大化前景与背景的类间方差自动计算阈值光照慢变时仍能维持稳定的分割效果。极性由卡号颜色决定卡号是深色、卡面是浅色用 THRESH_BINARY_INV得到白字黑底反过来用 THRESH_BINARY。极性反掉的典型表现是 ROI 内黑白颠倒数字区域变成全黑连通域统计时一个字符都提不出来。闭运算是把离散数字连成长条的关键。水平核宽度直接决定连接效果常用参数如下参数常用值调大条件调小条件水平核宽15~25数字间距大、需要跨过空白连成一条时数字间距小、凸起字油墨溢出导致笔画偏粗时垂直核高3~7图片有轻微倾斜需要容错时卡号上下方有文字避免连成一块时迭代次数1同一张图里有粗细差异很大的字体时固定场景一般不用改2.3 轮廓筛选与 ROI 裁剪的完整实现闭运算结果出来后用 findContours 找出所有连通区域再按面积和宽高比过滤。卡号条状区域一般占整张卡面的 5% 到 20%宽高比远大于普通文字块在一堆候选中特征非常明显。完整代码如下import cv2 import numpy as np def locate_card_number_roi(img_path): # 原始彩色图用于最后裁剪灰度图用于所有后续处理 img cv2.imread(img_path) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 中值滤波去掉点状反光核大小 3 保留笔画细节 gray cv2.medianBlur(gray, 3) # 深色字配浅色卡面需要反色Otsu 自动计算阈值 _, thresh cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU) # 闭运算把水平排列的数字连成一个长条 kernel cv2.getStructuringElement(cv2.MORPH_RECT, (17, 5)) closed cv2.morphologyEx(thresh, cv2.MORPH_CLOSE, kernel) # 只取外轮廓卡号内部的孤立点不影响筛选 contours, _ cv2.findContours(closed, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) h_img, w_img gray.shape candidates [] for cnt in contours: x, y, w, h cv2.boundingRect(cnt) area w * h # 面积过小的轮廓是噪声或卡面其他文字 if area h_img * w_img * 0.05: continue # 宽高比低于 4 的不是卡号条 if w / h 4.0 or w / h 15.0: continue candidates.append((x, y, w, h)) # 卡号通常在中下方按 y 坐标从大到小取第一个 candidates.sort(keylambda r: r[1], reverseTrue) if not candidates: return None x, y, w, h candidates[0] # 向外扩 4 像素防止分割时切掉数字边缘 x max(0, x - 4) y max(0, y - 4) w min(w_img - x, w 8) h min(h_img - y, h 8) return img[y:y h, x:x w]代码逻辑说明闭运算让卡号变成一条连通的白带findContours 才能把它当成一个完整轮廓提取。候选排序用 y 坐标降序而不是面积降序因为卡面右上角的品牌字往往面积很大但位置在卡号上方按 y 坐标取最靠下的候选更符合卡面排版规律。ROI 外扩 4 像素是为了补偿二值化对数字边缘的腐蚀如果外扩太大反而会把相邻文字带进来。2.4 定位失败时的三处排查第一极性反了。Otsu 把背景当成了前景白色连通域是整个卡面宽高比接近 1.0会直接被过滤掉。打印 thresh 中间结果确认数字是白色即可。第二闭运算核过大。水平核超过 30 时卡号和下方有效日期文字连成一片轮廓变成一个接近正方形的块。此时缩小水平核或者先对二值图做一次腐蚀断开误连的区域。第三卡号上方有大块品牌文字干扰。有些卡面右上角的品牌字又大又长面积和宽高比都达标。这种情况在代码里加一个位置约束只保留 y 坐标在卡面高度 40% 到 90% 区间的候选。提示定位得到的 ROI 建议直接存盘后续人工复核、扩充数据集、调整分割参数时能反复使用不用每次重跑定位链路。3. 银行卡数字分割连通域切分与粘连字符处理定位拿到 ROI 之后整个项目最影响识别率的分水岭出现了把整条卡号切成独立的数字字符。分割错一个字符后面模型再准也救不回来。3.1 用连通域完成基础切分ROI 是彩色图先转灰度再二值化。字符不粘连时connectedComponentsWithStats 是最快的切分方式它对每个白色连通区域给出外接矩形和面积按 x 坐标排序后依次输出即可。实现代码def split_digits(roi_img): # ROI 转灰度Otsu 二值化极性保持和定位阶段一致 gray cv2.cvtColor(roi_img, cv2.COLOR_BGR2GRAY) _, bin_img cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU) # 8 连通统计每个白色区域的外接框和面积 num_labels, labels, stats, _ cv2.connectedComponentsWithStats( bin_img, connectivity8) digits [] for i in range(1, num_labels): x, y, w, h, area stats[i] # 面积小于两倍行高的连通域视为噪声 if area bin_img.shape[0] * 2: continue digit bin_img[y:y h, x:x w] digits.append((x, digit)) # 所有字符按外接框 x 坐标从左到右排序 digits.sort(keylambda item: item[0]) return [d for _, d in digits]代码说明二值化极性必须先确认数字区域在白底上呈现黑色时必须反色成白字黑底否则连通域统计会把整块背景误认为前景。面积下限用“两倍行高”作为阈值能滤掉小数点和印刷纹理。排序用外接框的 x 坐标这保证输出字符顺序和卡号书写顺序一致后面拼字符串时不用再做排序处理。3.2 粘连字符的垂直投影切分凸起压印卡号的字符间距经常被油墨挤到接近零相邻两个数字会合并成一个连通域。此时连通域切分输出的块宽度接近两个字符直接送模型必错。常见做法是改用垂直投影切分把二值图按列累加白色像素得到一条投影曲线字符间隙在曲线上表现为零值区间也就是波谷按波谷位置切开即可。切分前必须保证卡片基本水平。倾斜超过 2 度时投影波谷被抹平切分误差会明显增大。所以投影之前先对 ROI 做一次边缘直线检测计算卡面倾角再用仿射变换旋转校正。投影切分代码def split_by_projection(bin_img, min_gap2): # 按列累加白色像素得到垂直投影曲线 col_sums np.sum(bin_img 0, axis0) # 找出连续空白列的起止位置 cut_points [] in_gap False for i, v in enumerate(col_sums): if v 0: if not in_gap: gap_start i in_gap True else: if in_gap: # 间隙太短只可能是噪声或笔画毛刺不切 if i - gap_start min_gap: cut_points.append(gap_start) cut_points.append(i) in_gap False if not cut_points: return [bin_img] parts [] xs [0] cut_points [bin_img.shape[1]] for j in range(0, len(xs) - 1, 2): x1, x2 xs[j], xs[j 1] if x2 - x1 4: continue # 小于 4 像素的片段是噪声 parts.append(bin_img[:, x1:x2]) return parts参数 min_gap2 表示至少要有 2 个空白列才算有效间隙避免一个像素宽的噪点造成误切。宽度小于 4 像素的片段直接丢弃这种片段通常是数字“1”的边缘毛刺或卡面纹理。这套方法和训练分割模型相比见效更快因为它用的是卡号自身的排版规律而不是模型学出来的统计特征。粘连不严重时优先用投影法粘连严重多个字符连成一大块时再考虑形态学腐蚀拆解或接入分割网络。3.3 切分结果的等比归一化切出的每个字符宽高不一致直接送 CNN 不行。常见做法是保持宽高比缩放到 28×28 画布中央而不是简单拉伸。拉伸会把一个细长的“1”变成宽度很大的形状模型学到的笔画结构就失真了。归一化函数def normalize_digit(digit_img, size28): h, w digit_img.shape if h 0 or w 0: return np.zeros((size, size), dtypenp.uint8) # 按最长边等比缩放让字符占画布约 80% scale (size * 0.8) / max(h, w) nh, nw max(1, int(h * scale)), max(1, int(w * scale)) # 放大时用三次插值缩小时用区域插值 interp cv2.INTER_CUBIC if scale 1.0 else cv2.INTER_AREA resized cv2.resize(digit_img, (nw, nh), interpolationinterp) # 放到画布中心保持模型输入的空间分布一致性 canvas np.zeros((size, size), dtypenp.uint8) y0 (size - nh) // 2 x0 (size - nw) // 2 canvas[y0:y0 nh, x0:x0 nw] resized return canvas这里 size 决定模型输入分辨率scale 系数 0.8 保证字符边缘不贴边插值方式根据放大缩小自动切换。这个函数在训练和推理两阶段必须使用同一份代码两边的预处理不一致是上线后精度下跌最常见的原因之一。提示切分阶段把每个字符按真实类别存入对应目录积累到一定量后用这些真实样本微调 CNN效果远好过继续加合成数据。4. 用 TensorFlow 训练数字识别模型数据、结构与推理数字识别是一个 10 类分类问题不需要大模型关键是训练数据的构成和预处理一致性。4.1 训练数据来源与组合比例最省事的起步数据是 MNISTtf.keras 内置加载用来验证整个流程足够。但 MNIST 是手写体和卡号常用的印刷体差别明显直接拿来训练的模型对印刷体的边缘锐度和字符粗细不敏感真实卡面识别率往往只有 97% 左右。自己凑数据的标准做法是两条腿走路先用几种不同字体生成合成样本加噪声、缩放、旋转、模糊再从真实卡面 ROI 里人工挑选切分好的字符每类补 200 到 500 张。合成数据撑起模型的基础能力真实数据负责微调两者比例在 8:2 到 9:1 之间效果都不错。4.2 轻量 CNN 结构与推荐超参识别单个数字用 LeNet 级别的网络就足够了。三个卷积块加一个全连接层参数量不到 50 万CPU 上推理一次不到 1 毫秒。网络结构和推荐超参如下层类型核/单元数输出形状说明Conv2D32 个 3×3(28, 28, 32)提取笔画边缘MaxPool2D2×2(14, 14, 32)降分辨率Conv2D64 个 3×3(14, 14, 64)组合笔画特征MaxPool2D2×2(7, 7, 64)降分辨率Conv2D64 个 3×3(7, 7, 64)加深特征表达Flatten—3136展平为向量Dense128128ReLU 激活Dropout0.5128抑制过拟合Dense1010softmax 输出训练超参推荐值调整说明batch_size64数据量小用 32显存不足用 16learning_rate1e-3训练后期降到 3e-4optimizerAdam默认 beta 参数即可epochs15配合早停 patience3losssparse_categorical_crossentropy标签为整数时的标准选择4.3 训练脚本与模型保存import tensorflow as tf from tensorflow.keras import layers, models, callbacks, datasets # 加载 MNIST转成 (28,28,1) 的 float32 并归一化到 0~1 (x_train, y_train), (x_test, y_test) datasets.mnist.load_data() x_train x_train.reshape(-1, 28, 28, 1).astype(float32) / 255.0 x_test x_test.reshape(-1, 28, 28, 1).astype(float32) / 255.0 # 数字分类网络结构参数与上面的表格对应 model models.Sequential([ layers.Conv2D(32, 3, activationrelu, paddingsame, input_shape(28, 28, 1)), layers.MaxPooling2D(2), layers.Conv2D(64, 3, activationrelu, paddingsame), layers.MaxPooling2D(2), layers.Conv2D(64, 3, activationrelu, paddingsame), layers.Flatten(), layers.Dense(128, activationrelu), layers.Dropout(0.5), layers.Dense(10, activationsoftmax) ]) model.compile(optimizeradam, losssparse_categorical_crossentropy, metrics[accuracy]) model.fit(x_train, y_train, validation_data(x_test, y_test), batch_size64, epochs15, callbacks[callbacks.EarlyStopping(patience3, restore_best_weightsTrue)]) model.save(digit_cnn.keras)训练参数说明归一化的astype(float32) / 255.0必须在 reshape 之后执行顺序反过来会先做整数除法丢掉精度。padding 用 same 保持卷积输出尺寸不变让池化层按固定尺度降采样。EarlyStopping 的 patience 为 3表示验证集指标连续 3 个 epoch 不提升就停止restore_best_weights 让模型回到验证集最优时的权重。保存格式用 .keras之后 load_model 可以直接还原结构和权重。4.4 训练和推理预处理不一致的坑模型训练好之后最容易出问题的地方反而不在网络本身。训练时输入是 0 到 1 之间的 float32推理时直接把 0 到 255 的 uint8 送入模型模型表现会完全异常甚至出现所有数字都预测成同一类的情况。排查方法是在 predict 之前打印输入张量的 dtype 和最大值确认和训练时对齐。这个不一致问题在换机器、换 Python 进程、改部署代码时最容易引入。5. 端到端集成把定位、分割、模型推理串成一条管线前三章的模块都稳定后剩下的工作是整合成一条完整识别管线输入图片路径输出卡号字符串和整体置信度。5.1 管线段与各模块的职责边界管线各段职责必须清楚定位只负责输出 ROI分割只负责把 ROI 内的数字切成单字符模型只接受 28×28 的归一化图输出 10 类的概率分布。任何一段都不越权去改别的段的结果。整合代码如下def recognize_card_number(img_path, model): # 第一步OpenCV 定位卡号 ROI roi locate_card_number_roi(img_path) if roi is None: return None, 0.0 # 第二步ROI 内二值化并切分数字 gray cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY) _, bin_img cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU) digits split_digits(bin_img) # 第三步卡号长度约束常规为 16 或 19 位 if not 12 len(digits) 19: return None, 0.0 # 第四步整卡字符合成一批只调用一次模型推理 batch [] for d in digits: norm normalize_digit(d, 28) batch.append(norm.astype(float32) / 255.0) if not batch: return None, 0.0 probs model.predict(np.stack(batch), verbose0) nums [] confs [] for p in probs: cls int(np.argmax(p)) nums.append(cls) confs.append(float(p[cls])) # 第五步Luhn 校验与最小置信度双重把关 if not luhn_check(nums) or min(confs) 0.9: return None, min(confs) return .join(map(str, nums)), min(confs)第五步是关键所在。单字符准确率 99% 时16 位数字全部识别正确的概率只有约 85%。也就是说整体识别有相当一部分错误来自单字符偶发误差Luhn 校验能在这一层把错误卡号拦截下来宁可返回失败让用户重拍也不能输出错号。5.2 Luhn 校验与长度约束Luhn 算法从卡号最右侧一位开始隔位乘 2乘 2 后超过 9 就减 9最后全部求和能被 10 整除则通过。这个算法能识破任意单个数字错误也能识破相邻数字交换。实现代码非常短def luhn_check(nums): total 0 # 从右往左遍历偶数位乘 2 for i, n in enumerate(reversed(nums)): if i % 2 1: n * 2 if n 9: n - 9 total n return total % 10 0用法注意nums 必须保持从左到右的原始顺序Luhn 校验才能正确发挥作用。如果分割输出本身少了一位整张卡大概率过不了校验这时应该直接判定识别失败不要试图通过枚举补位救回来。长度约束放在分割结果之后先用 12 到 19 位这个宽松范围过滤掉明显错误再进 Luhn 严格校验。5.3 批量推理与耗时优化另一个常见性能问题是循环里逐个调用 model.predict。每次 predict 都有固定的图执行开销16 个数字分割好之后如果逐个推理耗时是批量推理的十几倍。第 5.1 节代码里先把所有字符统一 normalized 成 batch再一次性 predict整体延迟能压到单次推理的水平。如果并发量再大可以把模型导出成 SavedModel 用 TensorFlow Serving 部署或者转换 TFLite 到移动端本地推理。无论哪种部署方式前面的 OpenCV 定位和分割逻辑都保持一致模型输入输出接口也不变切换成本主要在后端环境配置不需要改动识别业务代码。注意Luhn 校验失败的识别结果不要尝试“修正”。宁可返回失败并请求重新拍摄也不能输出一个拼凑出来、看起来合理但不属于持卡人的卡号。6. 卡号识别精度到瓶颈后的三个调优方向6.1 顶帽运算消除卡面反光光照不均是最常见的识别率杀手直接 Otsu 会把反光区域的字符烧成背景。处理思路是用形态学顶帽原图减去开运算结果把大范围光照梯度当作背景扣除。开运算核要大于字符笔画通常取 15×15 或更大kernel cv2.getStructuringElement(cv2.MORPH_RECT, (15, 15)) flat cv2.morphologyEx(gray, cv2.MORPH_TOPHAT, kernel) _, thresh cv2.threshold(flat, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU)顶帽操作之后字符区域比背景亮阈值极性切回 THRESH_BINARY。这个手法在卡面有渐变阴影的场景下比直方图均衡更稳因为它不放大噪声专扣背景亮度变化。6.2 语义约束比单字置信度更可靠模型输出的每个单字置信度只能说明“这个字形像某个数字”不能说明“整体是不是一张合法卡号”。卡号本身有长度约束、BIN 前缀、Luhn 校验三重语义信息。把这三重约束放在同一层判断里能挡住单字符置信度很高但整卡不合法的输出。置信度阈值卡 0.9 是一个经验起点实际业务里根据误报成本调整宁可漏识别不要错识别。6.3 监控三个工程指标而不是只看 loss模型训练看 accuracy上线后要看的是另外三个数单字识别率、整卡通过率、平均处理耗时。单字识别率反映分割和模型的基础能力整卡通过率反映 Luhn 校验后的业务可用度平均处理耗时决定部署规格。这三个指标比模型结构参数更能决定项目能不能上线。每次调参或改预处理先跑一套端到端测试记录这三个值的变化再判断改动方向是否正确。本文还有配套的精品资源点击获取