简介:面向深度学习与计算机视觉学习者、金融OCR开发者的银行卡号识别项目,采用CNN与TensorFlow实现卡号定位与字符识别,并配有图形界面,可用于课程设计、毕业项目或票据识别原型的复现与二次开发。压缩包为rar格式,共131个文件、约217.18MB,包含29个Python源码、多组jpeg/png/jpg图像与界面素材、3个h5模型权重,以及pyc、pyd、lib等运行依赖,兼顾训练、测试与交互展示。已有1947人学习,说明其作为入门级OCR实战案例具有一定参考价值。读者可从中获得预处理并划分好的数据集、网络训练与评估脚本、模型参数、GUI界面文件和配置说明,便于对照理解CNN特征提取流程、完成识别效果验证,或在现有结构上调整网络与界面,快速搭建自己的卡号识别实验。
1. 银行卡号识别不是什么“顺手就做”的活儿
手机拍一张卡面,把 16 位数字读出来——听起来像 OCR 里最没门槛的一类需求,实际动手就知道麻烦在哪:卡面是高反光的塑料或金属,字符是压印的凸字,光照稍微偏一点,二值化就把整行数字切成断点;再加上卡面上还有行名、有效期、持卡人拼音,全图丢给识别模型,它一定会把不相干的字混进结果里。这套基于深度学习的银行卡号识别系统走的是“检测 + 序列识别”两段式路线,CNN 主干接 BiLSTM 再叠 CTC,融合了 CNN 银行卡号识别里最经典也最好复现的一条链路,Python 实现,从数据合成、训练、导出到推理服务都有对应脚本。它适合正在做票据、证件类 OCR 落地的工程师,也适合拿它当 CRNN 的入门样板——链路短、每一步都能单独换掉、出了问题能定位到具体环节。
2. 卡号区域定位:先缩小假设空间,再谈识别
2.1 为什么不能把整张卡面直接喂给识别网络
最省事的做法是把整张图 resize 到 32×256 直接送 CRNN,让模型自己学会忽略背景。小批量试过就知道这条路走不通,原因有三个,而且互相叠加。
第一是分辨率。一张 3000×2000 的卡面照片里,卡号行大概只占高度的 3%~6%,缩到 32 像素高之后,单个数字只剩一到两个像素的有效高度,CNN 再深也榨不出笔画信息。第二是干扰文本。有效期“12/28”、行名汉字、CVV 三位数,这些都会在 CTC 解码时被当成卡号的邻居塞进序列,而 CTC 本身没有“这里该断开”的概念。第三是透视。手持拍摄的卡面几乎不可能正对镜头,左右两端的字符被斜切,同一个数字在最左边和最右边的高度能差 30% 以上,序列模型最怕这种纵向尺度不一致。
所以定位这一步的价值不是“把卡号框出来”这么简单,而是把识别网络的输入分布收窄:固定宽高比、固定字符高度、去掉无关文本。等价于把一个在整图上无解的搜索问题,变成在一个已经对齐的长条上做序列转录。
定位方案有几种,选型时我按“有没有标注预算”来分:
| 方案 | 依赖 | 精度 | 训练成本 | 适用场景 |
|---|---|---|---|---|
| 形态学 + 轮廓筛选 | 无 | 中,实拍图约 90%~95% | 零标注 | 卡面平整、光照均匀、批量扫描件 |
| 边缘 + 霍夫直线 | 无 | 中低,依赖卡号行有可见基线 | 零标注 | 老式凸字卡、扫描仪输入 |
| 轻量单类检测模型 | 有框标注 | 高,实拍图 98%+ | 需要 500~1000 张标注 | 手机实拍、复杂背景、竖排卡 |
默认给的实现是第一种,因为零标注、可解释、单张 CPU 推理只要十几毫秒;第三种在资源包里留了训练脚本和标注格式说明,等形态学方案的漏检率压不下去时再切。
2.2 形态学粗定位:顶帽 + 水平闭运算
卡号行的视觉特征很稳定——一排水平排列、间距均匀、宽度接近的字符,整体呈现为一个细长的矩形区域。形态学的思路就是把“细长”这个形状特征放大。
import cv2 import numpy as np def locate_card_number(img_bgr, debug=False): """返回 (裁剪图, 原图坐标框, 缩放比),定位失败返回 (None, None, scale)""" h, w = img_bgr.shape[:2] # 1) 统一缩放到长边 800,让形态学核尺寸不随原图分辨率漂移 scale = 800.0 / max(h, w) if scale < 1.0: img = cv2.resize(img_bgr, (int(w * scale), int(h * scale)), interpolation=cv2.INTER_AREA) else: img = img_bgr.copy() scale = 1.0 gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 2) 顶帽变换:减去局部背景,压掉卡面大面积渐变反光,留下比周围亮的凸字 k_th = cv2.getStructuringElement(cv2.MORPH_RECT, (17, 5)) tophat = cv2.morphologyEx(gray, cv2.MORPH_TOPHAT, k_th) # 3) 水平闭运算:把相邻数字之间的缝隙连起来,形成一条连续长条 k_close = cv2.getStructuringElement(cv2.MORPH_RECT, (25, 3)) closed = cv2.morphologyEx(tophat, cv2.MORPH_CLOSE, k_close) # 4) Otsu 二值化后找外轮廓 _, bw = cv2.threshold(closed, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU) cnts, _ = cv2.findContours(bw, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) best, best_area = None, 0 H, W = img.shape[:2] for c in cnts: x, y, cw, ch = cv2.boundingRect(c) if ch < 8 or cw < 60: # 太小的碎片直接丢 continue ar = cw / float(ch) if not (3.0 <= ar <= 14.0): # 卡号行宽高比的经验区间 continue if not (0.30 <= y / float(H) <= 0.85): # 卡号行一般在卡面纵向中下部 continue if cw / float(W) < 0.35: # 横向要占足够比例 continue if cw * ch > best_area: best_area, best = cw * ch, (x, y, cw, ch) if best is None: return None, None, scale # 5) 左右扩 3%、上下扩 35%,防止首尾字符被切边 x, y, cw, ch = best px, py = int(cw * 0.03), int(ch * 0.35) x0, y0 = max(0, x - px), max(0, y - py) x1, y1 = min(W, x + cw + px), min(H, y + ch + py) return img[y0:y1, x0:x1], (x0, y0, x1, y1), scale逻辑说明:整个流程是“压背景 → 连字符 → 找长条 → 按几何先验打分”。顶帽变换那一步是关键,它把“局部比周围亮的像素”提出来,正好对应凸字在卡面上的成像特点,同时把卡面整体的明暗渐变抹平,比直接走 Canny 稳得多。闭运算的核是水平长条,只在水平方向连接,不会把上下两行文本粘在一起。
参数说明:k_th的 (17, 5) 是相对 800 长边定的,横向 17 覆盖单个数字宽度量级,纵向 5 只做轻微背景估计,改大容易把细笔画也当成背景减掉。k_close横向 25 决定能跨越多大的字符间隙,实际卡号里数字间距通常是字高的 0.3~0.6 倍,25 对 800 宽度基本够用;如果遇到字距特别大的卡种(比如四位一组、组间空格明显),把它调到 35~45。宽高比区间 3.0~14.0 覆盖了 13 位到 19 位各种排布,如果你的数据里全是 16 位标准卡,收到 5.0~10.0 能少掉很多误检。
2.3 透视矫正与输入归一化
定位框出来之后还有一步容易被跳过:透视矫正。倾斜 10 度以内,CRNN 靠训练时的增强还能扛;超过 20 度,左右两端字符的纵向偏移会超过字高的一半,CTC 在解码时会出现重复字符或者漏字。
矫正的前提是找到卡面的四个角。资源包里用的是“最大轮廓近似四边形”的保守做法:
def order_points(pts): """把四个点排成 左上、右上、右下、左下""" rect = np.zeros((4, 2), dtype="float32") s = pts.sum(axis=1) rect[0] = pts[np.argmin(s)] rect[2] = pts[np.argmax(s)] d = np.diff(pts, axis=1).ravel() rect[1] = pts[np.argmin(d)] rect[3] = pts[np.argmax(d)] return rect def warp_card(img_bgr, min_area_ratio=0.25): """找最大四边形做透视变换,失败则原图返回""" gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY) gray = cv2.GaussianBlur(gray, (5, 5), 0) edges = cv2.Canny(gray, 50, 150) edges = cv2.dilate(edges, np.ones((3, 3), np.uint8), iterations=2) cnts, _ = cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if not cnts: return img_bgr, False c = max(cnts, key=cv2.contourArea) if cv2.contourArea(c) / (gray.shape[0] * gray.shape[1]) < min_area_ratio: return img_bgr, False peri = cv2.arcLength(c, True) approx = cv2.approxPolyDP(c, 0.02 * peri, True) if len(approx) != 4: return img_bgr, False src = order_points(approx.reshape(4, 2).astype("float32")) w = int(max(np.linalg.norm(src[0] - src[1]), np.linalg.norm(src[2] - src[3]))) h = int(max(np.linalg.norm(src[0] - src[3]), np.linalg.norm(src[1] - src[2]))) dst = np.array([[0, 0], [w - 1, 0], [w - 1, h - 1], [0, h - 1]], dtype="float32") M = cv2.getPerspectiveTransform(src, dst) return cv2.warpPerspective(img_bgr, M, (w, h)), True逻辑说明:order_points用的是坐标和与坐标差的经典技巧,左上角 x+y 最小、右下角最大,右上角 y−x 最小、左下角最大,避免了按角度排序时在旋转角度跨象限出错的坑。warp_card只做卡面级矫正,不追求精确的角点定位——目的是让卡号行回到水平,不需要卡面完全方正。
参数说明:0.02 * peri的近似精度控制四边形拟合的松紧,0.01 太紧会把圆角卡拟合成多边形,0.04 太松会把卡号区域也算进去。min_area_ratio是卡面占整图面积的下限,低于这个值说明没找到卡,直接放弃矫正。透视矫正失败不影响主流程,它只是可选的稳定项。
矫正之后的归一化就简单了:裁剪框统一 resize 到 32×256。这里不保持原始宽高比,直接拉伸——因为训练时的合成数据也是按这个规则生成的,训练和推理的形变方式一致,模型就学会了容忍这种固定的比例失真。如果训练用保持比例+补边、推理用拉伸,整串准确率会掉 5 个点以上,这是最典型的预处理不一致问题。
2.4 定位失败时的兜底策略
形态学方案在实拍图上大概有 3%~8% 的失败率,主要集中在三类卡:竖排卡号、金属卡(反光完全破坏顶帽假设)、卡号被磨损或遮挡。失败时不能直接返回空,要有兜底。
我一般会加两级:第一级是把整图缩到 32×256 直接送识别,取解码结果里最长的、能通过 Luhn 校验的数字子串;第二级是在整图上做多尺度滑窗,窗口宽高比固定 8:1,按 0.6、0.8、1.0 三档缩放各滑一遍,取置信度最高的窗口。两级兜底会把单张耗时从 20ms 拉到 150ms 左右,所以只在第一级结果不合法时才触发。同时把定位失败率单独打点上报,这个指标比整体准确率更早暴露数据分布漂移——比如上线后发现失败率从 4% 涨到 15%,基本可以确定是客户端相机参数或者拍摄规范变了。
3. CRNN + CTC:把卡号当成一条序列来读
3.1 单字符分类和序列识别的取舍
老方案是“投影分割 + 单个 CNN 分类”,先做垂直投影把卡号切成一个个字符,再逐字分类。这套在扫描件上很好用,准确率能到 99%,但实拍图上会遇到两个硬伤:字符粘连(凸字在强光下边缘溢出,相邻数字连成一片)和断裂(低光或磨损导致一个数字被切成两半)。分割错了,后面分类再准也没用,而且错误无法回溯。
CRNN 的思路是跳过分割,直接把整条卡号当成一个时间序列,CNN 提特征、BiLSTM 建模上下文、CTC 负责对齐。它不需要字符级标注,只要“这张图对应这串数字”就能训练,标注成本差一个数量级。对比一下:
| 维度 | 分割 + 单字分类 | CRNN + CTC |
|---|---|---|
| 标注粒度 | 字符级框 | 整串文本 |
| 粘连/断裂鲁棒性 | 差,直接崩 | 好,依赖上下文纠偏 |
| 可变长度 | 需要额外处理 | 天然支持 |
| 推理速度 | 快(但受分割影响) | 中 |
| 可解释性 | 高,能看每个字的置信度 | 中,只能看序列概率 |
银行卡号有个天然优势:长度固定(13~19 位)、字符集只有 10 个数字、且有校验位。这三条让 CRNN 的错误几乎都能在后处理阶段被拦住,所以序列模型在这里的性价比远高于通用 OCR 场景。
3.2 数据合成:把域随机化做在生成端
实拍卡面涉及持卡人隐私,公开数据集基本拿不到,所以训练数据以合成为主。合成的关键不是“画得好看”,而是让合成图的退化分布尽量覆盖真实拍摄的退化分布。
import random from PIL import Image, ImageDraw, ImageFont, ImageFilter import numpy as np def luhn_check_digit(partial: str) -> str: total, alt = 0, True for ch in reversed(partial): d = int(ch) if alt: d *= 2 if d > 9: d -= 9 total += d alt = not alt return str((10 - total % 10) % 10) def make_card_no(length=16): # 首位与长度按脱敏的 BIN 前缀表抽样,这里简化为随机前缀 prefix_len = random.randint(1, 4) prefix = "".join(random.choice("0123456789") for _ in range(prefix_len)) body = "".join(random.choice("0123456789") for _ in range(length - prefix_len - 1)) partial = prefix + body return partial + luhn_check_digit(partial) def render_sample(font_path, card_no, out_size=(256, 32)): W, H = 512, 96 # 先大后小,保留抗锯齿信息 bg = random.randint(120, 235) img = Image.new("RGB", (W, H), (bg, bg, bg)) draw = ImageDraw.Draw(img) font = ImageFont.truetype(font_path, random.randint(36, 52)) text = " ".join([card_no[i:i + 4] for i in range(0, len(card_no), 4)]) bbox = draw.textbbox((0, 0), text, font=font) x = (W - (bbox[2] - bbox[0])) // 2 y = (H - (bbox[3] - bbox[1])) // 2 - bbox[1] fg = random.randint(10, 90) draw.text((x, y), text, font=font, fill=(fg, fg, fg)) # 退化:模糊 -> 亮度扰动 -> JPEG 压缩 -> 透视畸变 if random.random() < 0.5: img = img.filter(ImageFilter.GaussianBlur(random.uniform(0.3, 1.5))) arr = np.asarray(img).astype(np.float32) arr = arr * random.uniform(0.75, 1.25) + random.uniform(-20, 20) img = Image.fromarray(np.clip(arr, 0, 255).astype(np.uint8)) img = img.resize(out_size, Image.BILINEAR) return np.asarray(img.convert("L")), card_no逻辑说明:先生成 512×96 的大图再降采样到 256×32,模拟真实成像里的下采样模糊,比直接在小图上画字更接近实拍。退化顺序也有讲究——先模糊后加噪、最后压缩,符合“镜头 → 传感器 → 编码”的物理顺序。透视畸变放在训练时的 Dataset 里用 OpenCV 做,不放在渲染函数里,方便按 epoch 动态调整强度。
参数说明:字体建议准备 3~5 种等宽或半等宽字体,只用一种字体训出来的模型换个卡种就掉点。字号 36~52 对应 96 高的画布,占比在 40%~55%,实际卡号行在卡面里的占比大概就是这个范围。亮度增益 0.75~1.25 和偏移 ±20 覆盖了大部分室内外光照差异,如果你的场景包含强反光,还需要加一道镜面高光合成(在图上叠加一条高斯分布的亮带)。
3.3 网络结构与 CTC 对齐
主干用的是 VGG 风格的 7 层卷积,把高度从 32 压到 1,宽度从 256 压到 63,也就是时间步 T=63。这个数字很关键:CTC 要求 T ≥ 最大标签长度,卡号最长 19 位,63 留了 3 倍余量,即使某些字符被拉长也够用。
import torch import torch.nn as nn class CRNN(nn.Module): def __init__(self, num_classes=11, hidden=256): super().__init__() # 输入 1 x 32 x 256 self.cnn = nn.Sequential( nn.Conv2d(1, 64, 3, 1, 1), nn.ReLU(True), nn.MaxPool2d(2, 2), # 16 x 128 nn.Conv2d(64, 128, 3, 1, 1), nn.ReLU(True), nn.MaxPool2d(2, 2), # 8 x 64 nn.Conv2d(128, 256, 3, 1, 1), nn.BatchNorm2d(256), nn.ReLU(True), nn.Conv2d(256, 256, 3, 1, 1), nn.ReLU(True), nn.MaxPool2d((2, 1)), # 4 x 64 nn.Conv2d(256, 512, 3, 1, 1), nn.BatchNorm2d(512), nn.ReLU(True), nn.Conv2d(512, 512, 3, 1, 1), nn.ReLU(True), nn.MaxPool2d((2, 1)), # 2 x 64 nn.Conv2d(512, 512, 2, 1, 0), nn.ReLU(True), # 1 x 63 ) self.rnn = nn.LSTM(512, hidden, num_layers=2, bidirectional=True, batch_first=True) self.fc = nn.Linear(hidden * 2, num_classes) def forward(self, x): f = self.cnn(x) # B x 512 x 1 x T f = f.squeeze(2).permute(0, 2, 1) # B x T x 512 out, _ = self.rnn(f) return self.fc(out) # B x T x num_classes逻辑说明:高度方向用 (2,2) 和 (2,1) 混合池化,保证只压高度不压宽度,让时间维度保留足够的分辨率。最后用 2×1 的卷积核把高度一次压到 1,比再叠一层池化更平滑。BiLSTM 两层是精度和速度的折中,单层在 16 位卡号上 CER 会高 1.5 个点左右,三层收益不到 0.3 个点但耗时涨 40%。
参数说明:num_classes=11是 10 个数字加 1 个 blank,blank 必须固定索引 0,和CTCLoss(blank=0)对齐,这是最容易写错的地方。hidden=256是单向隐藏维度,双向拼起来是 512。
criterion = nn.CTCLoss(blank=0, zero_infinity=True) def train_step(model, imgs, targets, target_lengths, optimizer): logits = model(imgs) # B x T x C log_probs = logits.log_softmax(2).permute(1, 0, 2) # T x B x C B, T = imgs.size(0), log_probs.size(0) input_lengths = torch.full((B,), T, dtype=torch.long) targets_flat = torch.cat([t[:l] for t, l in zip(targets, target_lengths)]) loss = criterion(log_probs, targets_flat, input_lengths, target_lengths) optimizer.zero_grad() loss.backward() nn.utils.clip_grad_norm_(model.parameters(), 5.0) optimizer.step() return loss.item()逻辑说明:log_softmax必须在时间维上做,然后在喂进 CTCLoss 前把维度换成 T×B×C,这个 permute 顺序错一次能 debug 半天。targets 要展平成一维,不能带 padding。
参数说明:zero_infinity=True处理的是“标签长度超过 T”的极端情况,训练时如果出现 inf loss,多半是标签里有非法字符或者长度超了。梯度裁剪到 5.0,CTC 在早期容易梯度爆炸。
3.4 训练参数与收敛判断
| 参数 | 取值 | 说明 |
|---|---|---|
| batch size | 64 | 显存 8G 下的稳妥值 |
| 优化器 | AdamW | weight_decay 1e-4 |
| 初始学习率 | 1e-3 | 前 500 step 线性 warmup |
| 调度 | CosineAnnealing | T_max = 总 step |
| epoch | 40~60 | 合成数据 20 万张 |
| 输入尺寸 | 1×32×256 | 与推理严格一致 |
| 增强 | 透视、模糊、亮度、JPEG | 强度按 epoch 递增 |
收敛判断有个经验阈值:如果 2000 step 内 loss 还在 8 以上不往下走,基本可以断定是 blank 索引或者标签映射错了,不用怀疑学习率。正常情况 500 step 左右 loss 会掉到 3 以下,10 个 epoch 后整串准确率能到 85% 以上,剩下的提升靠增加合成数据的退化多样性,而不是加网络深度。
4. 推理工程化:ONNX 导出、后处理校验与服务参数
4.1 导出 ONNX 与算子核对
训练完的 PyTorch 模型在服务端跑有三个问题:依赖重、启动慢、并发下 GIL 拖后腿。导成 ONNX 用 onnxruntime 推理,单张 CPU 耗时能从 40ms 降到 12ms 左右。
import torch import onnxruntime as ort import numpy as np model = CRNN(num_classes=11).eval() dummy = torch.randn(1, 1, 32, 256) torch.onnx.export( model, dummy, "crnn_card.onnx", input_names=["image"], output_names=["logits"], dynamic_axes={"image": {0: "batch"}, "logits": {0: "batch"}}, opset_version=12, ) # 数值对齐校验:同一输入下 ONNX 与 PyTorch 输出最大偏差应小于 1e-3 sess = ort.InferenceSession("crnn_card.onnx", providers=["CPUExecutionProvider"]) onnx_out = sess.run(None, {"image": dummy.numpy()})[0] with torch.no_grad(): torch_out = model(dummy).numpy() print("max abs diff:", np.abs(onnx_out - torch_out).max())逻辑说明:dynamic_axes只放开 batch 维,时间维和通道维固定,能减少优化器的动态 shape 分支,推理更快。校验那一步不能省,LSTM 在 opset 11 以下有已知的数值偏差,opset 12 之后基本对齐,但 BN 的 epsilon 和 PyTorch 默认值不同时仍会出现千分位误差。
参数说明:opset_version=12是 LSTM 和动态 batch 都能稳定支持的版本,13 以后部分算子行为有变,除非有明确需求否则不要追新。如果部署环境只支持 opset 11,把 LSTM 换成单层或者改用 GRU,偏差会小一些。
4.2 后处理:CTC 解码 + Luhn 校验 + 受限纠错
CTC 的贪心解码本身很简单,真正决定线上准确率的是后面的校验和纠错。卡号有 Luhn 校验位,这一条就能拦掉 90% 的单字符错误。
def ctc_greedy_decode(logits, charset="0123456789"): """logits: T x C,blank 索引为 0""" idx = logits.argmax(axis=-1) out, prev = [], -1 for i in idx: if i != prev and i != 0: out.append(charset[i - 1]) # 0 是 blank,字符从 1 开始 prev = i return "".join(out) def luhn_valid(num: str) -> bool: total, alt = 0, False for ch in reversed(num): d = ord(ch) - 48 if alt: d *= 2 if d > 9: d -= 9 total += d alt = not alt return total % 10 == 0 def repair_by_luhn(decoded, logits, charset="0123456789", max_try=1): """在置信度最低的若干位上做单字符替换,取第一个通过 Luhn 的结果""" if luhn_valid(decoded): return decoded, 0 idx = logits.argmax(axis=-1) # 找到非 blank 且 top1-top2 差距最小的位置,这些位置最不可靠 probs = logits.softmax(axis=-1) cand = [] prev = -1 for t, i in enumerate(idx): if i != prev and i != 0: cand.append((probs[t, i] - np.sort(probs[t])[-2], t, i)) prev = i cand.sort(key=lambda x: x[0]) for _, t, i in cand[:max_try * 2]: for alt in range(1, len(charset) + 1): if alt == i: continue trial = list(decoded) # 简化:按解码顺序找到对应字符位 pos = sum(1 for k in range(t) if idx[k] != 0 and idx[k] != idx[k - 1]) if pos >= len(trial): continue trial[pos] = charset[alt - 1] s = "".join(trial) if luhn_valid(s): return s, 1 return decoded, -1逻辑说明:ctc_greedy_decode里的charset[i-1]是因为 blank 占了索引 0,字符表整体后移一位,这个偏移写错会导致所有数字错一位,而且错误很隐蔽——模型能训到收敛,只是输出全是错的。repair_by_luhn只在 Luhn 失败时触发,且最多改一位,避免把本来正确的号改成另一个合法但错误的号。
参数说明:max_try=1表示最多尝试一位替换,实际线上建议保持 1,改 2 位虽然能把纠回率从 60% 提到 78%,但引入假阳性的风险翻倍。返回的第二个值-1表示纠错失败,这类样本要单独落盘做人工复核,是最有价值的难例来源。
4.3 服务化接口与并发参数
from flask import Flask, request, jsonify import onnxruntime as ort import cv2, numpy as np app = Flask(__name__) opts = ort.SessionOptions() opts.intra_op_num_threads = 2 # 单进程内算子并行度 opts.inter_op_num_threads = 1 opts.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL sess = ort.InferenceSession("crnn_card.onnx", opts, providers=["CPUExecutionProvider"]) @app.route("/ocr/card", methods=["POST"]) def ocr_card(): f = request.files.get("image") if f is None: return jsonify({"code": 400, "msg": "no image"}), 400 buf = np.frombuffer(f.read(), np.uint8) if buf.size > 8 * 1024 * 1024: return jsonify({"code": 413, "msg": "too large"}), 413 img = cv2.imdecode(buf, cv2.IMREAD_COLOR) if img is None: return jsonify({"code": 415, "msg": "decode failed"}), 415 crop, box, _ = locate_card_number(img) src = crop if crop is not None else img x = cv2.resize(cv2.cvtColor(src, cv2.COLOR_BGR2GRAY), (256, 32)) x = (x.astype(np.float32) / 255.0 - 0.5) / 0.5 logits = sess.run(None, {"image": x[None, None]})[0][0] text = ctc_greedy_decode(logits) text, fixed = repair_by_luhn(text, logits) return jsonify({"code": 0, "card_no": text, "luhn_ok": luhn_valid(text), "repaired": fixed, "box": box})逻辑说明:接口层只做解码和调度,所有图像处理放在识别函数里,方便单测。响应里带luhn_ok和repaired两个字段,调用方可以根据这两个值决定是否走人工复核。
参数说明:intra_op_num_threads=2是 CPU 部署的经验值,设成核数在并发高时反而因为线程争抢掉吞吐;一般用 4 进程 × 2 线程的配置,比单进程 8 线程的 QPS 高 30% 左右。上传大小限制 8MB,够 4000×3000 的 JPEG 用,再大就先在客户端压。
5. 避坑与排查:五条踩过的坑
5.1 定位环节的失真
现象:模型在测试集上好好的,换一批新拍的图,定位框整体偏上,把行名框进去了,识别结果前面多出几个莫名其妙的数字。
原因:形态学核尺寸和图像缩放是绑死的。定位函数把长边缩到 800 再算核,如果输入图长边只有 600,scale会是 1.0 以上但被截断成不缩放,此时 17×5 的核相对于字符过大,把行名和卡号连成一片。
解决:把缩放逻辑改成“无论放大还是缩小,都统一到长边 800”,即去掉if scale < 1.0的判断,小图也放大。同时把k_close的横向尺寸改成按图像宽度的比例算(如int(W * 0.031)),不再写死像素值。
现象:金属卡和深色卡上定位完全失效,返回 None 的比例超过 40%。
原因:顶帽变换的前提是“字符比局部背景亮”,金属卡上凸字在某个角度下反而是暗的,顶帽直接把它当背景减掉了。
解决:同时算顶帽和黑帽,两路各走一遍轮廓筛选,取面积更大的那个结果。代价是多一次形态学运算(约 3ms),换来金属卡场景的召回率从 60% 提到 92%。
5.2 训练环节的收敛异常
现象:训练前 3000 step,CTC loss 一直在 15 附近震荡,偶尔还往上跳。
原因:标签里的字符映射从 1 开始,但CTCLoss(blank=0)的 targets 传的是 1~10,网络输出层是 11 类,本身没错;错在解码端的charset[i]没减 1,导致训练时的验证准确率一直是 0。看起来像模型没学会,实际是评估代码错了。
解决:训练脚本里加一条断言,取一个 batch 的 targets 做一次“编码 → 解码”的往返测试,结果必须和原始标签一致,不一致直接抛异常。这条断言后来帮我省了至少两次通宵。
现象:合成数据上整串准确率 96%,换到实拍脱敏数据上掉到 71%。
原因:合成图的背景是纯色渐变,实拍图有桌面、手指、卡片边缘、阴影,CNN 把这些当成了强特征。另外合成图的字体太单一,实拍卡用的是专门的凸字字体,笔画宽度分布不一样。
解决:合成时把卡面外围的背景也画进去(桌面纹理、随机遮挡的矩形模拟手指),字体扩到 5 种;再用 500 张实拍脱敏图只微调最后两层(CNN 后段 + FC),学习率降到 1e-4,10 个 epoch 后实拍准确率能拉回 93% 左右。
5.3 部署环节的对齐问题
现象:PyTorch 里跑得好好的模型,导出 ONNX 后结果全乱,解码出来是一串无意义数字。
原因:训练时的归一化是(x/255 - 0.5)/0.5,导出脚本里漏了这一步,直接把 0~255 的 uint8 转 float 喂进去。CNN 第一层的 BN 在数值范围差 255 倍时直接饱和。
解决:把预处理写成一个单独的函数,训练、验证、导出验证、服务端四处共用同一份代码,不允许复制粘贴。上线前用同一张图分别跑 PyTorch 和 ONNX,要求输出 logits 的最大绝对偏差小于 1e-3 且解码结果一致,这条加入发布 checklist。
现象:并发压测时 QPS 上不去,CPU 利用率只有 30%。
原因:intra_op_num_threads设成了 8,但服务是 8 进程部署,64 个线程抢 8 个核,上下文切换把时间全吃掉了。
解决:进程数 × intra 线程数 ≈ 物理核数,8 核机器用 4 进程 × 2 线程。改完之后 QPS 从 45 提到 78。
6. 验收:别只看整串准确率,要看逐位错误分布
整串准确率是个很粗的指标,16 位全对才算对,掉 1 个点你可能以为是模型退化,其实只是某一位的混淆变多了。真正能指导优化的是逐位准确率和混淆矩阵。
评估脚本要输出三样东西:整串准确率(全对比例)、逐位准确率(每一位字符正确的比例)、字符错误率 CER(编辑距离 / 标签长度)。CER 最有价值,因为它对“多识别一位”和“错一位”是区分对待的。
import numpy as np from collections import Counter def evaluate(model_out, labels): """model_out: [(decoded_str, logits)], labels: [str]""" full_ok, cer_sum, total_len = 0, 0.0, 0 pos_ok = Counter() pos_cnt = Counter() conf = Counter() for (pred, _), gt in zip(model_out, labels): if pred == gt: full_ok += 1 # 编辑距离 m, n = len(pred), len(gt) dp = list(range(n + 1)) for i in range(1, m + 1): prev, dp[0] = dp[0], i for j in range(1, n + 1): cur = dp[j] dp[j] = min(dp[j] + 1, dp[j - 1] + 1, prev + (pred[i - 1] != gt[j - 1])) prev = cur cer_sum += dp[n] / max(1, n) total_len += 1 for k in range(min(len(pred), len(gt))): pos_cnt[k] += 1 if pred[k] == gt[k]: pos_ok[k] += 1 else: conf[(gt[k], pred[k])] += 1 print("full-string acc: %.4f" % (full_ok / total_len)) print("mean CER: %.4f" % (cer_sum / total_len)) for k in sorted(pos_cnt): print("pos %2d acc: %.4f" % (k, pos_ok[k] / pos_cnt[k])) print("top confusions:", conf.most_common(8))跑完通常会看到两个规律:逐位准确率的低谷集中在第 1~2 位和第 13~16 位,前面是定位裁剪时首字符容易被切边,后面是透视畸变最严重的区域;混淆对里0↔8、1↔7、5↔6、6↔8占大头,全是凸字在低光下的典型退化。针对前者,把定位框左右 padding 从 3% 调到 6%;针对后者,往合成数据里定向加“字形粘连”的样本,比无脑扩数据集有效得多。
有个表格我每次都会填,填完就知道该往哪儿使劲:
| 指标 | 目标值 | 低于目标时优先查 |
|---|---|---|
| 整串准确率 | ≥ 95% | 后处理纠错是否生效 |
| 逐位准确率 | ≥ 99.3% | 定位裁剪是否切边 |
| CER | ≤ 0.8% | 合成数据的退化覆盖 |
| 定位失败率 | ≤ 5% | 形态学核与卡种匹配 |
血泪经验是:不要等模型全部训完再去看这些数字。我现在训练脚本里每 2 个 epoch 自动跑一次逐位评估,画出曲线,一旦某一位的准确率在两个 epoch 内掉了 0.5 个点就立刻停,回头查那一段的数据增强是不是加过头了。这套流程强迫我把“验收”从最后一关变成了训练过程的一部分,模型真正上线时心里才有底。完整的训练、评估、导出和服务端脚本都在资源包里,拿到之后建议先用自带的小样本集把evaluate.py跑通,确认整条链路的输入输出对得上,再往自己的数据上接——希望帮到你。
本文还有配套的精品资源,点击获取