ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

基于OpenCV模板匹配的信用卡卡号识别实战

基于OpenCV模板匹配的信用卡卡号识别实战 简介基于Python和OpenCV的信用卡卡号数字识别项目面向图像处理学习者和金融业务自动化开发者重点解决卡号区域定位、字符分割与数字识别问题适合作为课程设计或实战练手项目。资源包共9个文件包括2个Python源码文件、6张信用卡测试图像和1份Markdown说明文档代码与图像分离存放目录整洁便于阅读和复用。核心脚本id_templeate_match.py实现卡号数字的模板匹配识别utils.py封装图像灰度化、二值化、轮廓提取等预处理操作readme.md则描述运行方式与实现思路。压缩包仅541KB轻量便携已有224人学习浏览可帮助快速掌握OpenCV轮廓检测、透视变换和OCR模板匹配等关键技能值得图像处理方向初学者参考实践。1. 一张信用卡照片为什么要用 OpenCV 做卡号识别我上个月在处理银行流水截图时发现直接调 Pytesseract 识别卡号光照稍差就会把“4”认成“1”而且很难定位是第几位出错。切回 OpenCV 模板匹配方案后利用信用卡压印字体固定的特性先定位卡号区域再逐字符匹配准确率明显更稳每一步还有可视化中间结果可以排查。这个项目就是把流程代码化从一张信用卡照片开始经过灰度化、形态学预处理、轮廓定位最后用模板匹配输出完整卡号。它适合刚学完图像处理基础、想在一个完整项目里串起阈值、轮廓、形态学、匹配这些知识点的开发者。2. 模板匹配的前置工作灰度化、二值化与数字模板切分模板匹配法不直接从整张卡图里找数字而是先把参考图的 0-9 十个数字切出来做成“标准数字库”。这个项目里的参考图是images/ocr_a_reference.png它是标准的信用卡数字印刷体。切模板这一步做得越干净后面cv2.matchTemplate的结果就越可靠。2.1 为什么从参考图切数字而不是手工画数字很多人第一次做卡号识别会自己去画数字或者用渲染库生成数字做模板。但这样生成的数字与真实卡号在笔画粗细、衬线、压印阴影上都有差异匹配得分会普遍偏低。项目里的参考图就是真实卡号的同款字体直接从它切出来的模板与待识别数字的分布一致阈值可以设得很严。切模板的流程是读取 → 灰度化 → 二值化 → 反色 → 找轮廓 → 按 x 排序 → 裁剪并统一尺寸。2.2 模板图像预处理代码下面是utils.py里切分数字模板的核心代码我把它从id_templeate_match.py里拆了出来import cv2 import numpy as np def build_digit_templates(ref_path): # 参考图是灰度图直接按单通道读取 ref cv2.imread(ref_path, cv2.IMREAD_GRAYSCALE) # 背景是白色、数字是深色先取反让数字变白色 _, ref_bin cv2.threshold(ref, 0, 255, cv2.THRESH_BINARY_INV cv2.THRESH_OTSU) # 提取外轮廓 cnts, _ cv2.findContours(ref_bin, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) # 轮廓按左上角 x 坐标排序保证 0 在左、9 在右 cnts sorted(cnts, keylambda c: cv2.boundingRect(c)[0]) templates {} for c in cnts: x, y, w, h cv2.boundingRect(c) if w 15 or h 30: # 过滤参考图上的噪点和标点 continue digit_roi ref_bin[y:y h, x:x w] # 统一缩放到固定尺寸后续匹配时无需再按宽高归一化 digit_roi cv2.resize(digit_roi, (57, 88), interpolationcv2.INTER_NEAREST) templates[len(templates)] digit_roi return templates这段代码有两个关键点一是THRESH_BINARY_INV THRESH_OTSU组合让 OpenCV 自动计算二值化阈值而不是手工试固定值二是用RETR_EXTERNAL只取最外层轮廓避免数字内部的孔洞比如 6、8、9被当成独立目标。裁剪之后统一resize到 57x88这个尺寸不是固定的但需要和后面识别时的候选字符尺寸一致否则matchTemplate的模板窗口会明显大于或小于候选区域。需要说明的是ref_bin是 0/255 的二值图cv2.resize默认的线性插值会把边缘变灰这在匹配时会带来一定误差。我一般会改用cv2.INTER_NEAREST保留二值边缘的锐利度。下面的参数表总结了切模板阶段几个关键决策参数我用的值作用调整建议threshold 组合THRESH_BINARY_INV OTSU自适应反色二值化背景均匀时用 OTSU背景复杂则先做高斯模糊轮廓模式RETR_EXTERNAL取外轮廓剔除内部孔洞如果轮廓断裂改用 RETR_CCOMP面积过滤w15 或 h30 丢弃去掉参考图上的水印和噪声根据实际图片分辨率微调resize 尺寸57x88统一模板与候选字符大小必须与后面切候选数字的尺寸一致我通常会把这个模块单独放在utils.py因为第 3 章定位到卡号区域后切出来的候选字符也需要走同样一套缩放工具函数共享能减少维度不一致的问题。实际跑下来模板切分最常见的错误是在循环里用固定索引赋值它依赖findContours返回顺序而轮廓顺序并不保证从左到右所以必须先用cv2.boundingRect(c)[0]排序再用len(templates)作为字典键才能让数字编号稳定对应该字符的真实值。3. 定位卡号区域形态学闭运算、轮廓筛选与候选数字切割卡号照片不像截图那样干净周围有卡组织 Logo、有效期、持卡人姓名和背景纹理。模板匹配如果直接扫全图会把背景里的图案也识别成数字。所以要先通过形态学操作把 16 位卡号“连”成一个或几个候选块再按宽高比筛掉无关区域。这一章的操作对象是images/credit_card_04.png这类标准信用卡照片光照相对均匀可以用固定参数加形态学的方案。3.1 形态学梯度与闭运算连接字符信用卡卡号通常由 4 组数字组成每组 4 位组间有空白。我的做法是先计算形态学梯度突出字符边缘再用水平方向的长核做闭运算把同一组的字符连成一块。这一步的关键是闭运算的内核不能太长否则会把两组数字连起来导致最后切出来的框超过 4 个字符太短又连不上间隙较大的卡号。例如图像宽度 640 时48x11 的矩形核比较合适因为单个数字宽约 20 像素4 个数字加 3 个间距约 95 像素取一半大小的水平核就能把字符粘起来。如果图像来自高清扫描件参数要按比例放大。我一般会把核宽放到函数参数里而不是硬编码方便不同分辨率复用。3.2 定位卡号四组数字的代码import cv2 import numpy as np def locate_card_digits(image, group_kernel_w48): gray cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) gray cv2.bilateralFilter(gray, 5, 80, 80) # 保留边缘同时降噪 # 形态学梯度膨胀减腐蚀突出字符区域的明暗变化 gray_dilate cv2.dilate(gray, cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5))) gray_erode cv2.erode(gray, cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5))) grad cv2.absdiff(gray_dilate, gray_erode) # 反色后做二值化梯度图里字符区域更亮 _, grad_bin cv2.threshold(grad, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU) # 水平闭运算把同一组数字连接成块 kernel cv2.getStructuringElement(cv2.MORPH_RECT, (group_kernel_w, 11)) close cv2.morphologyEx(grad_bin, cv2.MORPH_CLOSE, kernel) close cv2.dilate(close, kernel, iterations1) # 再膨胀一次避免断连 cnts, _ cv2.findContours(close, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) groups [] for c in cnts: x, y, w, h cv2.boundingRect(c) if w 80 or w 500 or h 20 or h 100: continue groups.append((x, y, w, h)) groups sorted(groups, keylambda r: r[0]) return groups, close这里为什么要用bilateralFilter因为信用卡表面有细小的纹理直接二值化会产生大量椒盐噪声而双边滤波可以在平滑噪声时保持数字边缘的锋利比高斯模糊更适合这种场景。筛选阈值w 80 or w 500是基于图像分辨率设定的信用卡切图里每组卡号的宽度一般在 80 到 120 像素之间而 Logo 或文字块往往超宽。如果你手里的图是高分辨率大图需要等比放大这些数值。3.2.1 从候选块中切出单个数字拿到候选块坐标后我在块内再次检测轮廓切出每个数字def extract_digit_rois(card_image, groups): gray cv2.cvtColor(card_image, cv2.COLOR_BGR2GRAY) rois [] for gx, gy, gw, gh in groups: block gray[gy:gy gh, gx:gx gw] _, block_bin cv2.threshold(block, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU) # 组内字符轮廓按 x 坐标排序 cnts, _ cv2.findContours(block_bin, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) cnts sorted(cnts, keylambda c: cv2.boundingRect(c)[0]) for c in cnts: x, y, w, h cv2.boundingRect(c) if h 15 or w 10: continue roi block_bin[y:y h, x:x w] roi cv2.resize(roi, (57, 88), interpolationcv2.INTER_NEAREST) rois.append(roi) return rois这里单独对block做二值化而不是用刚才定位时用的梯度图是因为梯度图已经丢失了字符内部细节直接拿来切分会导致内部有空洞。而THRESH_BINARY_INV针对原始灰度图的暗色数字更合理。切出来的 ROI 必须与模板统一到同一个尺寸这也是第 2 章强调尺寸一致的原因。如果发现某些候选块被漏切或重复切往往是内部轮廓断裂造成的。下表列出几个常见调整点症状原因调整一组卡号被切成 5-6 个字符数字间有笔画粘连或阴影干扰块内二值化前用较小的中值滤波整组卡号被识别成一个轮廓闭运算核太宽把 group_kernel_w 降到 30 左右某个数字缺失该数字光照过暗被二值化滤掉改用adaptiveThreshold字符内部出现多个轮廓反色阈值过高笔画中间变白固定阈值 127 代替 OTSU这一步输出的rois是列表顺序就是从左到右的数字。因为已经按groups顺序、再按组内轮廓 x 坐标排序所以可以放心交给第 4 章匹配。4. 模板匹配识别卡号matchTemplate 原理与参数调优候选数字已经切出来了识别过程其实就是一个多维数组的滑窗对比。cv2.matchTemplate把模板小图放到候选图每个位置滑一下返回一个相关度矩阵矩阵最大值位置就是最优匹配。但这里要处理的候选图已经和模板统一到 57x88两者大小相同所以滑窗只有一种可能位置匹配结果退化成一个相关值直接用minMaxLoc读出来即可。4.1 调用 matchTemplate 与模板库匹配下面是把候选 ROI 和 10 个模板逐一匹配的代码def match_digit(roi, templates): scores {} for d, tpl in templates.items(): # TM_CCOEFF_NORMED 对亮度和对比度不敏感适合光照变化 result cv2.matchTemplate(roi, tpl, cv2.TM_CCOEFF_NORMED) _, score, _, _ cv2.minMaxLoc(result) scores[d] score best_digit max(scores, keyscores.get) best_score scores[best_digit] return best_digit, best_scoreTM_CCOEFF_NORMED全称是归一化相关系数法它计算的是两个窗口减去各自均值后的相关性所以候选数字整体偏亮或偏暗时分数不会剧烈波动。信用卡卡号在压印时凸起表面受侧光影响左边亮右边暗是常有的事用这个方法可以匹配上。相反TM_SQDIFF_NORMED适合灰度分布完全一致的场景在照片类输入里更容易误判。我实际对比过TM_CCOEFF_NORMED分数通常在 0.85 以上而TM_SQDIFF_NORMED对光照变化更敏感需要把阈值调到很小。如果你处理的是屏幕截图而不是实拍照片改用TM_SQDIFF_NORMED反而更准因为截图没有光照问题。4.2 识别完整卡号并格式化匹配完每个字符后需要把结果聚合成卡号字符串并按 4-4-4-4 分组输出def recognize_card_number(rois, templates): digits [] confs [] for roi in rois: d, s match_digit(roi, templates) digits.append(str(d)) confs.append(s) raw .join(digits) # 只有 16 位时才认为识别完整不足时按实际长度输出 groups [raw[i:i4] for i in range(0, len(raw), 4)] return -.join(groups), raw, confs这段代码最后返回三个值可打印的分组卡号、原始连续数字、每个数字的置信度列表。记录置信度很重要后面验证脚本会用它来筛掉低分样本。如果没有置信度输出卡号 14 位与 16 位混在一起时排查会非常吃力。4.3 阈值与多卡号场景单张图只有一个卡号时直接取最高分就行。但同一张信用卡照片可能包含两行数字或者数字下方有凸出纹理这时需要增加一个最小阈值比如best_score 0.7时标记为未知字符而不是强行取较高分。下表给出常见匹配方法的适用场景。方法适用场景本项目推荐度TM_CCOEFF_NORMED光照不均、字符边缘有阴影的照片高TM_CCORR_NORMED模板和候选图对比度一致中易在高亮区域误报TM_SQDIFF_NORMED屏幕截图、印刷体无光照干扰低自定义像素差二值图完全一致可作辅助过滤在第 3 章切出的 ROI 数量如果小于 16recognize_card_number仍然能拼接出几个分组但长度不对。处理这种异常时我通常不阻塞识别流程而是把长度标记为异常把图片连同raw和confs一起写入日志等批量跑完再人工复核。这种策略比在识别中途直接报错更高效。5. 实战中的倾斜校正、误匹配排除与识别率验证前面流程假设信用卡图片已经摆正。实际拿手机拍的卡面经常会旋转 5 度以上如果不校正候选字符切出来后宽高比就变了和模板 57x88 的缩放结果会产生畸变匹配率明显下降。所以我在id_templeate_match.py中加了一个倾斜校正步骤。5.1 用 minAreaRect 检测卡牌角度并旋转检测整张卡的外轮廓求最小外接矩形。矩形和水平方向的夹角就是倾斜角如果角度绝对值大于 2 度就用仿射变换转正。代码思路如下def deskew_card(image): gray cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) _, bin_img cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY cv2.THRESH_OTSU) cnts, _ cv2.findContours(bin_img, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if not cnts: return image card_contour max(cnts, keycv2.contourArea) rect cv2.minAreaRect(card_contour) angle rect[2] # 调整为适合旋转的角度范围 if angle -45: angle 90 angle if abs(angle) 2: return image h, w image.shape[:2] center (w // 2, h // 2) matrix cv2.getRotationMatrix2D(center, angle, 1.0) return cv2.warpAffine(image, matrix, (w, h), flagscv2.INTER_CUBIC, borderModecv2.BORDER_REPLICATE)这里angle的校正方向和minAreaRect返回的符号有关常见做法是根据矩形宽高判断长边方向。另一个容易出错的地方是旋转后卡牌边缘会引入黑边cv2.BORDER_REPLICATE能减少黑边对后续二值化的干扰。5.2 误匹配排除与验证脚本模板匹配高分辨率下偶尔会出现“1”和“7”互相认错、“0”和“8”混淆的情况。在验证阶段我会统计每张图的置信度分布快速定位哪些样本需要调整阈值。下面是批量验证脚本import glob import json results [] for img_path in glob.glob(images/credit_card_*.png): img cv2.imread(img_path) img deskew_card(img) groups, _ locate_card_digits(img) rois extract_digit_rois(img, groups) formatted, raw, confs recognize_card_number(rois, templates) results.append({ image: img_path, length: len(raw), card_number: formatted, min_confidence: min(confs) if confs else 0 }) with open(results.json, w, encodingutf-8) as f: json.dump(results, f, ensure_asciiFalse, indent2)跑完脚本后用min_confidence字段排序低于 0.75 的样本优先检查。这些低分样本大多是卡号区域有反光或手指遮挡适当调大第 3 章的闭运算核宽度能部分改善但遮挡严重时必须换图或做多帧取平均。5.3 一个最容易见效的排错技巧如果你在复现时发现卡号总是差一位可以先在locate_card_digits和extract_digit_rois之间插入cv2.imwrite(debug.png, block)把每组卡号区域保存下来。很多问题不是算法本身的逻辑而是二值化后数字边缘太粗模板匹配窗口无法对齐。一个有效技巧是把候选 ROI 和对应模板的resize插值方式保持一致都换成cv2.INTER_NEAREST匹配分数会立刻提升。把每一步中间结果落盘回放一遍通常比直接调阈值更快找到问题所在。本文还有配套的精品资源点击获取
返回列表