ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python+OpenCV+CNN:银行卡号识别与定位系统实战解析

Python+OpenCV+CNN:银行卡号识别与定位系统实战解析 简介Python基于深度学习的银行卡号识别与定位系统源码包面向计算机视觉、OCR及金融科技方向的开发者提供端到端的不定长银行卡号识别方案。项目采用CRNN架构卷积部分参考VGG循环部分使用双向LSTM并结合CTC损失函数可处理卡号长度不固定的实际问题同时附带GUI界面与数据集方便直接体验与二次开发。包内共59个文件包括20个Python脚本、训练图片样本、GIF/JPEG演示素材、GUI界面ui文件、requirements依赖说明及readme文档压缩包仅5.24MB轻量便于下载。已有450人学习下载适合有一定深度学习基础、希望复现OCR识别流程或基于现有代码调整训练参数的读者。通过内置cfg.py可灵活配置训练参数性能较弱的显卡使用默认设置即可高端显卡可增大batch size加速训练让不同硬件条件的使用者都能顺利开展实验。1. 当 Python 遇上银行卡号识别比模型更难的是把卡号准确地抠出来做银行卡号识别的人习惯把目光放在识别模型上真把整套流程跑起来才发现16 个数字里有十四个的准确率瓶颈都出在“抠图”这一步。这套 Python 基于深度学习的银行卡号识别与定位系统是定位加识别两段式完整方案不是只有训练脚本的半成品。系统前半段用 OpenCV 做卡号定位和数字分割后半段用 CNN 对切好的单字逐一识别覆盖预处理、分割、模型推理、置信度过滤全流程目录按模型、训练、推理、工具分开摆放方便替换自己的卡面样本测试。适合做卡证 OCR 的开发者也适合想拿完整项目练深度学习实战的初学者。下面按我最顺手的调参顺序拆解参数和坑都会说到。2. 卡号定位管线从 OpenCV 预处理到轮廓筛选的四个参数2.1 定位思路为什么先考虑图像处理而不是目标检测很多人第一反应是上目标检测模型觉得一切都是黑匣子喂数据就行。但银行卡号定位本质上是一个“找文本条带”的任务卡号区域非常规整横向长条、宽高比固定、位于卡面下方三分之一。这样的几何特征用形态学操作和轮廓筛选就足够稳定还能省掉标注和训练的开销。如果训练目标检测你需要几百上千张带框标注的卡面图片还要解决卡号可能被卡面 logo、卡种图标遮挡的问题。深度学习在这个系统里的真正用武之地是识别切好的单个数字而不是定位整个区域。所以整个定位阶段全部落在 OpenCV 上灰度化、边缘检测、闭运算、轮廓筛选、透视校正、投影切分。我一般会先把一张卡面照着这个流程跑一遍把中间结果用cv2.imwrite存下来确认每级输出没有断链再开始调参数。这一环节的逻辑很简单没必要迷信“玄学调参”每一步的输入输出都是可视化的。2.2 预处理参数灰度、高斯模糊、Canny 边缘检测import cv2 import numpy as np # 单通道灰度图是后续所有形态学操作的基础 gray cv2.cvtColor(cv2.imread(card.jpg), cv2.COLOR_BGR2GRAY) # 高斯模糊核大小 5x5sigmaX 传 0由核大小自动推导 # 核太大会把数字边缘磨平核太小(比如 3)压不住卡面底纹噪声 blur cv2.GaussianBlur(gray, (5, 5), 0) # Canny 边缘检测低阈值 80、高阈值 200 # 阈值太高会把反光偏弱的数字边缘丢掉太低会把印刷底纹全部勾出来 edged cv2.Canny(blur, 80, 200)参数说明GaussianBlur的 sigmaX 传 0 的意思是让 OpenCV 根据核大小自动计算标准差。Canny 的 80/200 可以先拿一张典型图片试观察边缘图里卡号轮廓是否被完整勾出如果卡号带在边缘图里是断断续续的通常是光照不均导致梯度不够可以尝试把低阈值降到 60但不建议低于 40否则底纹噪声会淹没真正的数字边缘。边缘图出来之后下一步是把离散的数字边缘亮条连成一个完整的连通域这里用闭运算# 闭运算 先膨胀再腐蚀把相邻的数字边缘亮条桥接起来 # 核宽 17 是根据“数字宽度 数字间距”估出来的 kernel cv2.getStructuringElement(cv2.MORPH_RECT, (17, 5)) closed cv2.morphologyEx(edged, cv2.MORPH_CLOSE, kernel)注意这个核宽 17 不是固定的。它取决于输入图像的分辨率如果卡片是手机拍的 1080p 大图数字宽度可能到 30 像素核宽可以放宽到 23如果是从视频帧里截出来的小图数字宽度只有 12 像素17 的核宽就容易把相邻数字粘连起来。我一般先打印卡片 ROI 的统计宽度再反向定核宽而不是第一版就跑通。2.3 轮廓筛选与透视校正宽高比、面积、四点变换闭运算完成后整张图里剩下的连通域不多可以开始筛选卡号区域。卡号带和普通文本区域的差别在于宽高比这是最有效的过滤条件。# 查找连通域。不同 OpenCV 版本返回值不一样统一起见做一次兼容 cnts_result cv2.findContours(closed, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE) cnts cnts_result[0] if len(cnts_result) 2 else cnts_result[1] candidates [] for c in cnts: x, y, w, h cv2.boundingRect(c) wh_ratio w / float(h) # 卡号区域经验值宽高比 5高度在 25~150 之间宽度至少 150 if wh_ratio 5 and 25 h 150 and w 150: candidates.append((w * h, (x, y, w, h), c)) # 按面积从大到小排序卡号区域通常是面积最大的长条 candidates.sort(keylambda item: item[0], reverseTrue)为什么宽高比取 516 位卡号拍出来通常 600~1000 像素宽、50~80 像素高宽高比接近 10而卡面上的 logo、文字块宽高比一般小于 4。如果图片里恰好有个长条图案面积排序通常会优先选中它所以在筛选里我会再做一次垂直投影波峰数量校验这个校验放到避坑部分展开。卡面不一定是正对摄像头拍的斜拍导致卡号带变成梯形。这时直接用boundingRect切出来的矩形会包含大量背景而且后面垂直投影分割会被背景干扰。我改用最小外接旋转矩形加透视校正def order_points(pts): # 把四个顶点排成左上、右上、右下、左下 rect np.zeros((4, 2), dtypefloat32) s pts.sum(axis1) # 左上角 xy 最小右下角 xy 最大 rect[0] pts[np.argmin(s)] rect[2] pts[np.argmax(s)] diff np.diff(pts, axis1) # 右上角 y-x 最小左下角 y-x 最大 rect[1] pts[np.argmin(diff)] rect[3] pts[np.argmax(diff)] return rect # 对面积最大的候选轮廓取最小外接矩形 area, (x, y, w, h), c candidates[0] rect cv2.minAreaRect(c) box cv2.boxPoints(rect) box order_points(box) # 旋转矩形的宽高可能颠倒统一到宽大于高 rw, rh int(rect[1][0]), int(rect[1][1]) if rw rh: rw, rh rh, rw dst np.array([[0, 0], [rw - 1, 0], [rw - 1, rh - 1], [0, rh - 1]], dtypefloat32) M cv2.getPerspectiveTransform(box, dst) warped cv2.warpPerspective(gray, M, (rw, rh))minAreaRect返回结果是旋转矩形它的 width、height 是根据旋转角度定的不能直接当实际宽高用必须先比较并统一到“宽大于高”否则变换出来的图像可能是躺着的后面投影切分全乱。透视变换的四个目标点顺序必须和box排序后的点一一对应这里order_points保证左上、右上、右下、左下的顺序一致。2.4 投影法分割把连续数字切成单字卡号带拉正之后就可以用投影法把 16 位数字逐个切出来。这一步的核心是先做二值化再统计白色像素在行、列上的分布。# 在拉正后的卡号带上做自适应二值化THRESH_BINARY_INV 让数字变前景 binary cv2.adaptiveThreshold(warped, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 15, 2) # 水平投影统计每一行前景像素数量自动裁掉上下留白 h_proj np.sum(binary, axis1) // 255 rows np.where(h_proj 0)[0] if len(rows) 0: raise RuntimeError(定位失败卡号带内没有可分割的前景像素) binary binary[rows[0]:rows[-1] 1, :] # 垂直投影统计每一列前景像素数量连续非零列就是一个字符块 v_proj np.sum(binary, axis0) // 255 segments [] start None for i, val in enumerate(v_proj): if val 0 and start is None: start i elif val 0 and start is not None: if i - start 3: # 小于 3 像素的断点视为噪声 segments.append((start, i - 1)) start None if start is not None: segments.append((start, len(v_proj) - 1))自适应阈值有两个参数值得说明。blockSize 15 表示每个像素的阈值由周围 15×15 邻域的加权均值决定卡号带局部亮度差异大时用它比全局阈值稳得多C 值 2 是从均值里减去的常数C 越大前景越少反光明显时我会把 C 调到 3 或 4避免整块亮斑被当成前景。segments天然按 x 坐标升序排列这正是后面识别模型需要的顺序。实际切出来的块数量不一定是 16可能是 14 到 19因为有些数字比如 1 会和相邻数字产生间距抖动或者两张字体卡的数字间距本来就不均匀。这个偏差不用怕识别模型按单字处理最后再拼字符串。注意如果 segments 数量明显偏离 14~19 这个区间优先怀疑闭运算核宽太大导致数字粘连或者二值化把卡面背景整块误判成了前景不要先怀疑模型。3. 数字识别模型轻量 CNN 的选型理由与可运行代码3.1 模型选型轻量 CNN 与 CRNN 的取舍数字切块出来后问题从序列识别变成了单字符分类10 个类别输入是 22×64 的灰度图。这个任务用轻量 CNN 就够了。很多人会问为什么不用 CRNN 加 CTCCRNN 适合的是不定长文本行比如发票整行、自然场景门牌号而银行卡号是定长 16 位并且已经在定位阶段切成单字再用序列模型属于大材小用。轻量 CNN 在这个场景的优势是快和稳。PyTorch 写的推理在 CPU 上单张 5 毫秒内出结果不需要 GPU配合 OpenCV 的预处理整条管线可以跑在普通办公电脑甚至树莓派上。相比之下 CRNN 虽然在某些倾斜文本上更鲁棒但对切块质量的要求反而更高省下的模型复杂度没有必要。3.2 网络实现CardNet 结构与 PyTorch 代码网络结构参考了 LeNet 的思路但把激活换成 ReLU、加了 BatchNorm深度加深到三层卷积。输入固定为 22×64 灰度图输出 10 类概率。import torch import torch.nn as nn class CardNet(nn.Module): def __init__(self, num_classes10): super().__init__() self.features nn.Sequential( # 第一层卷积1 通道灰度输入32 个 3x3 卷积核 nn.Conv2d(1, 32, kernel_size3, padding1), nn.BatchNorm2d(32), nn.ReLU(inplaceTrue), nn.MaxPool2d(2, 2), # 第二层64 个卷积核从边缘过渡到局部笔画 nn.Conv2d(32, 64, kernel_size3, padding1), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), nn.MaxPool2d(2, 2), # 第三层128 个卷积核抽象出数字的拓扑结构 nn.Conv2d(64, 128, kernel_size3, padding1), nn.BatchNorm2d(128), nn.ReLU(inplaceTrue), nn.MaxPool2d(2, 2), ) self.classifier nn.Sequential( nn.Flatten(), # 输入 22*64 - 三次池化后是 3*8128 通道 nn.Linear(128 * 3 * 8, 128), nn.ReLU(inplaceTrue), nn.Dropout(0.5), nn.Linear(128, num_classes), ) def forward(self, x): return self.classifier(self.features(x))三个卷积层都用 3×3 加 padding1特征图尺寸不衰减只有池化层负责缩小尺寸。BatchNorm 是我在这个任务里加得比较值的一层真实切块的质量参差不齐有的偏亮、有的偏暗BN 能把特征分布拉到相对稳定的范围。Dropout 0.5 放在全连接前防止训练集只有几百张时直接过拟合。这里要留意输入尺寸切块宽高比不固定必须统一到 22×64 再进网络而且不能裸resize因为有些数字比如 1很窄直接拉成 22×64 会把笔画撑宽让原本方正的 1 变成一坨横条。常见做法是先按比例缩放、保持笔画宽度不变再把四周补黑到 64×22这个逻辑我封装在后面的_pad_resize里。3.3 训练和微调MNIST 预训练 真实样本我自己做这个项目时不会从零标注十万张卡号。常见做法是先在 MNIST 上跑一个预训练再用真实卡面切块做微调。MNIST 有 6 万张手写数字虽然和印刷体字形差异不小但能让模型的底层卷积核学到笔画和边缘的基本特征微调时收敛快得多。import torch.nn.functional as F from torch.utils.data import DataLoader, TensorDataset # 假设 train_x 是 (N, 1, 22, 64) 的 numpy 数组train_y 是对应标签 # 真实切块数量每类 100~300 张就够MNIST 预训练可抽 2 万张 train_loader DataLoader(TensorDataset( torch.from_numpy(train_x).float(), torch.from_numpy(train_y).long() ), batch_size64, shuffleTrue) model CardNet(num_classes10) criterion nn.CrossEntropyLoss() optimizer torch.optim.Adam(model.parameters(), lr3e-4) scheduler torch.optim.lr_scheduler.StepLR(optimizer, step_size2, gamma0.5) for epoch in range(5): total_loss 0.0 for imgs, labels in train_loader: optimizer.zero_grad() out model(imgs) loss criterion(out, labels) loss.backward() optimizer.step() total_loss loss.item() scheduler.step() print(fepoch{epoch 1}, loss{total_loss / len(train_loader):.4f})学习率 3e-4 配 Adam 是最省事的一组参数训练数据量小时不建议把 lr 调到 1e-3 以上否则微调阶段很容易把 MNIST 学到的特征直接冲掉。StepLR 每两个 epoch 把学习率降一半最后几个 epoch 实质是在做精细调整。微调完成后模型文件的保存建议带上网络结构和参数如果用torch.save(model.state_dict())保存加载时要先实例化CardNet()如果直接用torch.save(model)加载时要注意 Python 和 torch 版本一致。这两种方式我都遇到过坑后面统一用 state_dict 加 pickle 打包骨架的方式。4. 端到端脚本把定位、分割、识别串成一条命令4.1 主流程整合识别器类与关键顺序定位和识别的代码分开调试没问题但真正用起来需要封装成一个入口。我从项目里抽象出一个识别器类把前面三章的函数串起来对外只暴露predict(img_path)。class CardRecognition: def __init__(self, model_path): self.model CardNet() self.model.load_state_dict(torch.load(model_path, map_locationcpu)) self.model.eval() def predict(self, img_path): gray cv2.imread(img_path, cv2.IMREAD_GRAYSCALE) warped self.locate(gray) # 返回拉正并裁剪后的卡号带 digits self.segment(warped) # 返回按 x 坐标升序排列的字符块 result [] for d in digits: # 统一尺寸保持长宽比缩放四周补黑到 64x22 d self._pad_resize(d, (64, 22)) x torch.from_numpy(d / 255.0).float().view(1, 1, 22, 64) with torch.no_grad(): prob torch.softmax(self.model(x), dim1) idx prob.argmax().item() conf prob[0, idx].item() result.append((idx, conf)) return result_pad_resize的逻辑是先把切块等比缩放到不超出 64×22 的最大尺寸然后放在 64×22 的画布中央上下左右补黑。这样切块里的数字笔画宽度不会被拉伸真实场景里识别率明显高于直接 resize。整个流程的关键顺序是先定位、再切割、最后识别绝对不能跳步。segment返回的顺序必须按照 x 坐标升序排好否则拼出来的卡号是乱序的我见过有人直接拿findContours的返回顺序去喂模型结果是一串完全错位的数字。4.2 置信度过滤与结果格式化模型输出的 softmax 概率并不是每次都可靠比如把 7 认成 1 时置信度往往在 0.5 到 0.7 之间徘徊。所以我不会直接输出字符串而是把低于阈值的字符标记为待人工确认。card_str low_conf [] for i, (digit, conf) in enumerate(result): if conf 0.6: low_conf.append((i, digit, conf)) card_str ? else: card_str str(digit) print(f识别结果: {card_str}) if low_conf: print(低置信度位置:, low_conf)置信度阈值 0.6 是我测试下来比较平衡的点。设 0.8 会有一批正常识别结果被误判为待人工确认增加审核工作量设 0.4 又会放过明显错误的字符。卡号是资金流程相关的东西我宁愿多标记几个可疑字符也不要把错误结果直接提交上游系统。4.3 中间结果 dump定位断链时先查哪一级调整个流程时最烦的是不知道哪一级出错。我习惯在项目里加一个可视化工具把中间结果全部 dump 到磁盘排查时一眼看过去就能定位问题。python tools/visualize.py --image card.jpg --output-dir debug/cv2.imwrite(f{out}/1_gray.png, gray) cv2.imwrite(f{out}/2_edged.png, edged) cv2.imwrite(f{out}/3_closed.png, closed) cv2.imwrite(f{out}/4_candidates.png, draw_candidates) cv2.imwrite(f{out}/5_warped.png, warped) for i, seg in enumerate(segments): cv2.imwrite(f{out}/digit_{i:02d}.png, binary[:, seg[0]:seg[1] 1])dump 中间结果对性能几乎没影响但调试价值极高。比如反光场景先看 2_edged 是否缺了数字边缘决定要不要上 CLAHE再看 5_warped 是否拉正决定是否需要调整四点校正顺序。如果只盯着最终结果猜很容易把一个本来没问题的地方反复调参。5. 避坑指南五个真实翻车案例与排查路径5.1 反光把卡号“洗”白了Canny 什么也检测不到现象同一张卡白天拍能识别换成台灯侧光拍卡面中央一条白色光斑定位直接失败打到项目里报错“没有候选区域”。原因光斑区域的灰度值和卡面背景几乎一样Canny 在亮区的梯度响应极低全局阈值化更会把亮斑误判为背景边缘断裂后闭运算根本接不起来。解决先对灰度图做 CLAHE 局部对比度增强再进 Canny。我一般这么处理clahe cv2.createCLAHE(clipLimit2.0, tileGridSize(8, 8)) enhanced clahe.apply(gray) edged cv2.Canny(cv2.GaussianBlur(enhanced, (5, 5), 0), 80, 200)clipLimit 控制局部对比度放大的上限取 1.5~3.0 之间都行tileGridSize 设 8×8改成 4×4 会容易产生块状伪影。增强之后卡号边缘的梯度响应会明显提升这是我在灯光环境不可控的采集场景里最常用的一个保底操作。5.2 数字粘连投影切出不间断长块现象垂直投影结果里出现一个宽度是其他字符两倍的大块模型对它输出的数字完全不着调经常是 9、8 之类笔画复杂的类被错误命中。原因闭运算核宽 17 对当前输入分辨率来说太宽了把相邻数字之间的间隙直接桥接起来另一种情况是卡号带里有斜体数字笔画倾斜导致相邻字符在垂直方向的投影本就交叠。解决先打印segments的宽度列表找宽度异常项。如果只有某个块是其他块的 1.5 倍以上可以对该列段再做一次形态学腐蚀把粘连点断开更根本的做法是把闭运算核宽从 17 降到 9~13以字符高度的 0.6~1.0 倍为基准重新估算。核宽越小分割越细但太细又会让单个数字被内部笔画的间隙断成两截这个平衡点要靠可视化工具去观察。5.3 手持斜拍导致宽高比过滤失效现象手机斜着拍卡面时卡号区域的投影宽高比从 8:1 掉到 3:1candidates 直接为空流程提前中断。原因透视变换压缩了水平方向原本细长的卡号带在图像里变成了接近矩形甚至倒梯形的区域轮廓筛选的阈值把它挡在门外。解决要先检测整张银行卡的外轮廓而不是直接找卡号。银行卡有标准横纵比 85.60×53.98mm大约 1.586:1找到这个四边形后做透视校正到标准尺寸再回到校正后的图上找卡号。这样卡号带的宽高比就不会因为拍摄角度出现巨大衰减宽高比下限可以保持在 5 以上。# 找整卡外轮廓时面积要占整图一定比例否则容易把背景桌面误检成卡 card_contour max(contours, keycv2.contourArea) if cv2.contourArea(card_contour) gray.shape[0] * gray.shape[1] * 0.25: raise RuntimeError(未找到有效银行卡外轮廓)5.4 凸印卡号在平射光下直接丢边缘现象传统凸字银行卡在自然光或平射光下拍摄数字边缘非常淡Canny 检测结果里卡号位置连不成形分割出来是断的。原因凸字卡号的灰度差异其实来自侧面阴影正对光源时阴影小时数字变成一片与背景同色的拱形边缘梯度不足。解决图像预处理层面能补的有限CLAHE 加 USM 锐化能提升一些响应但最有效的是在采集端用低位角光源。如果已经拿到无法重拍的图片我建议把识别策略从“边缘检测 投影”改成“背景差分 高频细节模板匹配”。要提醒的是这种硬件层面的事不要指望软件无限兜底项目排期就该把打光条件写进采集规范。5.5 背景 logo 抢走候选区域现象某张卡正面右上角有个饱和度很高的圆形 logoCanny 后 logo 区域产生大量边缘闭运算后形成一个面积很大的连通域排到了 candidates 第一位最终识别结果是一串乱码。原因logo 内部纹理丰富闭运算把这些纹理全部连成一片面积大、宽高比也不低骗过了轮廓筛选规则。解决加一道垂直投影波峰数量校验。真实卡号带切出来应该有 14~19 个数字块logo 区域通常只有 3~6 个。在轮廓筛选后对二进制图做分割如果len(segments)不在这个区间直接取下一条候选。同时加一个位置先验卡号通常位于卡面下半部分优先取 y 坐标偏下的候选区域。if not (14 len(segments) 24): # 校验不通过换下一个候选轮廓 continue这道规则比单纯调宽高比更贴合银行卡版式因为很多卡面的背景装饰本身就呈长条状但没有任何装饰会长成 16 个等宽字符。6. 进阶优化Luhn 校验、数据增强与 ONNX 导出6.1 用 Luhn 算法在模型输出后做硬校验银行卡号符合 Luhn 校验算法识别完 16 位数字后跑一遍能从结构上排除相当一部分识别错误。这个方法代价极低但能挡掉在那 1、7 混淆这类模型硬伤。def luhn_check(card_no): digits [int(ch) for ch in card_no if ch.isdigit()] if len(digits) not in (13, 16, 19): return False checksum 0 for i, d in enumerate(reversed(digits)): n d * 2 if i % 2 1 else d if n 9: n - 9 checksum n return checksum % 10 0原理是从右往左偶数位数字乘 2如果结果大于 9 就减 9全部累加后再对 10 取模。结果不为 0 说明一定不是合法卡号。我把这个校验放在置信度过滤之后校验不过的图片直接标记为需要重拍而不是让上游系统拿一个看似完整的 16 位数字去做后续绑卡。6.2 数据增强给切块制造更多“坏样本”真实切块里有模糊、旋转、缺笔画等情况训练数据里需要模拟这些场景。我按卡号切块的实际规律做增强旋转不超过 8 度亮度波动控制在 0.8~1.2 倍。import random def augment(digit_img): h, w digit_img.shape M cv2.getRotationMatrix2D((w / 2, h / 2), random.uniform(-8, 8), random.uniform(0.9, 1.1)) aug cv2.warpAffine(digit_img, M, (w, h)) aug np.clip(aug.astype(np.float32) * random.uniform(0.8, 1.2), 0, 255) return aug.astype(np.uint8)旋转超过 10 度的切块在真实场景里很罕见强加反而会引入无谓的类内差异。亮度扰动模拟的是反光和阴影0.8 倍让数字变暗1.2 倍模拟局部过曝这两种情况在卡证采集里出现频率最高。6.3 导出 ONNX 并在 CPU 上评估耗时模型验证完想落地我一般导出 ONNX脱离 PyTorch 环境也能跑推理。pip install onnx onnxruntimedummy torch.randn(1, 1, 22, 64) torch.onnx.export(model, dummy, cardnet.onnx, input_names[input], output_names[output], dynamic_axes{input: {0: batch}, output: {0: batch}})导出后单张预测在普通办公 CPU 上约 3~8 毫秒比 PyTorch eager 模式略快更重要的是部署环境不需要装 torch只依赖 opencv 和 onnxruntime。dynamic_axes 让 batch 维度可变批量跑历史卡面审核时能一次喂几十张。识别系统上线前我会专门跑一个五十张卡面的回归集记录识别正确率、平均置信度和 Luhn 通过率三个数字。从那以后我每次做卡证 OCR 项目都强制自己先把中间结果一级级 dump 出来肉眼确认定位没断链再谈优化网络。这套银行卡号识别与定位系统源码包的调试顺序也是如此从定位开始建立可视化基线再依次叠加校验和模型优化每一步都在前一版结果上做增量验证而不是最后一次性看个最终结果。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进