ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

OpenCV信用卡数字识别:从预处理到模板匹配的完整实践

OpenCV信用卡数字识别:从预处理到模板匹配的完整实践 简介一套基于Python与OpenCV的信用卡数字识别项目源码面向计算机视觉初学者和希望掌握OCR数字识别流程的开发者解决从信用卡图像中自动定位并识别凸印数字的问题涵盖预处理、数字定位、模板匹配识别的完整项目链路。资源共10个文件包含2个Python脚本模板匹配主程序与工具模块、7张测试图片及1个编译缓存文件压缩包仅550KB结构精简、便于快速运行。目前已有158人学习下载适合用于课程设计或项目实战参考。源码完整演示了图像灰度化、降噪、边缘检测、轮廓筛选等预处理手段并利用模板匹配技术对数字区域进行识别代码注释详细逐步说明每个环节的原理方便读者对照理解。无论初学者还是有一定经验的开发者都能从中获得清晰的项目骨架多张信用卡样例图可直观验证识别效果也为后续扩展其他OCR任务提供了可复用的处理思路。1. 用 OpenCV 做信用卡数字识别真正的分水岭在预处理把一张信用卡照片里的 16 位卡号自动读出来很多人的第一反应是上深度学习 OCR。但这个项目恰恰相反用 OpenCV 的模板匹配就能在固定版式上拿到接近满分的识别率整个工程只依赖 opencv-python 和 numpy。真正难的不是认数字而是找数字——卡号区域要能从背景里分离出来卡片要扶正粘连的字符要逐个切开。这套流程把灰度化、顶帽运算、Sobel 梯度、闭运算、轮廓检测、透视变换、模板匹配串成一条完整流水线。跑通之后你会发现大多数 OpenCV 图像处理项目都是同一套骨架换了参数。这个题目也是高校计算机视觉大作业里的常客如果你在规划计算机视觉学习路线它值得放在第一站。2. 识别原理与预处理模板匹配、Sobel 梯度与形态学运算整条识别流程的顺序是预处理 → 定位卡号区域 → 切分单字符 → 模板匹配。顺序不能乱上一步的输出格式直接决定下一步怎么写。环境上只需要执行pip install opencv-python配合 numpy 就够跑完整套代码。下面先从最后一步的模板匹配讲起因为预处理的每一件事都是在给匹配准备干净的输入。2.1 模板匹配为什么够用先界定识别边界模板匹配的原理是把一张小图当窗口在大图的每个位置滑动并计算相似度得分最高的位置就是匹配结果。它成立的前提是被识别对象的外观高度一致。信用卡上的卡号是凸印的等宽数字字体在所有主流卡组织上基本统一所以十张 0-9 的模板就能覆盖全部字符。相比之下训练一个深度学习识别器需要数据、标注、训练和部署在固定字体场景里属于不必要的复杂度。反过来模板匹配的边界也很明确字体变化、字符旋转、透视畸变都会让分数暴跌。所以这个项目真正的工程点在预处理和切分目的是把任意角度、任意光照下拍到的卡号归一化成和模板同字体、同尺度、同方向的干净字符。先界定问题再选工具这正是计算机视觉入门最容易漏掉的一课。很多人把识别率上不去归咎于模板不好实际八成是前面的定位和切分出了问题。2.2 构建数字模板库从一张 0-9 样张切出十个模板模板库的构建方式决定了后续所有匹配的上限。常见做法是准备一张白底深色、按顺序排列 0 到 9 的样张用和后面切分卡号几乎相同的代码自动切出十个模板。这段代码不长但把轮廓检测、排序、ROI 提取三个动作完整练了一遍。import cv2 import numpy as np def build_digit_templates(template_path, size(57, 88)): # 读入 0~9 样张白底、深色数字 gray cv2.cvtColor(cv2.imread(template_path), cv2.COLOR_BGR2GRAY) # 反二值化让数字变成白色前景 _, thresh cv2.threshold(gray, 10, 255, cv2.THRESH_BINARY_INV) # RETR_EXTERNAL 只取外轮廓避免数字内孔产生多余轮廓 contours, _ cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) templates {} # 按包围盒 x 坐标排序样张顺序就是 0..9 for i, c in enumerate(sorted(contours, keylambda c: cv2.boundingRect(c)[0])): x, y, w, h cv2.boundingRect(c) roi thresh[y:y h, x:x w] # 四周垫 5px 黑边防止缩放时笔画被裁切 roi cv2.copyMakeBorder(roi, 5, 5, 5, 5, cv2.BORDER_CONSTANT, value0) templates[i] cv2.resize(roi, size, interpolationcv2.INTER_AREA) return templatescv2.findContours在 OpenCV 4 中返回两个值第一个是轮廓列表不要再用 OpenCV 3 的三变量写法接收。排序要用包围盒的 x 坐标因为轮廓的第一个点不保证在最左边。copyMakeBorder加 5 像素黑边是给笔画留呼吸空间统一 resize 到 57×88这个尺寸是后续所有字符归一化的基准。模板切得好不好直接把十张缩略图用cv2.imshow拼起来看一眼就知道。注意阈值方向取决于样张配色。白底黑字用THRESH_BINARY_INV黑底白字用THRESH_BINARY原则是让前景数字变成 255背景变成 0。2.3 预处理流水线顶帽、Sobel 与闭运算的参数选择定位卡号区域的核心思路是把数字变成一块块彼此独立的白色区域。真实照片里有背景纹理、高光、有效期和持卡人姓名直接二值化什么都分不清。下面这套组合是这个任务里复用率最高的 OpenCV 图像处理链路参数按 300 到 600 像素宽的图片设定顺序操作核或参数作用1顶帽运算MORPH_RECT(9,3)提取比背景亮的细小结构抹平大范围光照差2Sobel x 方向梯度ksize3, ddepthCV_64F数字竖笔画产生强水平梯度3绝对值转换convertScaleAbs保留正负梯度避免二值化漏掉一半边缘4闭运算MORPH_RECT(9,3)把单个数字断开的笔画连接成实心块5Otsu 二值化THRESH_BINARY THRESH_OTSU自动求阈值适应光照变化6闭运算MORPH_RECT(17,5)把相邻数字合并成一个连通域def digit_group_binary(gray, close_kernel(17, 5)): # 1) 顶帽原图减去开运算结果 k1 cv2.getStructuringElement(cv2.MORPH_RECT, (9, 3)) tophat cv2.morphologyEx(gray, cv2.MORPH_TOPHAT, k1) # 2) 只算 x 方向梯度卡号竖笔画在这里响应最强 grad cv2.Sobel(tophat, cv2.CV_64F, 1, 0, ksize3) grad cv2.convertScaleAbs(grad) # 3) 小核闭运算把单个数字的笔画连成块 grad cv2.morphologyEx(grad, cv2.MORPH_CLOSE, k1) # 4) Otsu 自动求阈值不需要手调 _, thresh cv2.threshold(grad, 0, 255, cv2.THRESH_BINARY cv2.THRESH_OTSU) # 5) 大核闭运算把 4 个相邻数字并成一个组 k2 cv2.getStructuringElement(cv2.MORPH_RECT, close_kernel) return cv2.morphologyEx(thresh, cv2.MORPH_CLOSE, k2)顶帽运算会优先响应笔画这类细小结构同时把不均匀光照带来的大范围亮度差异直接去掉这是它比单纯直方图均衡更贴合本任务的原因。Sobel 的ddepth要显式写成CV_64F否则负梯度会被 uint8 截断成 0ksize只能取奇数。Otsu 阈值传 0由算法自动计算比固定阈值对光照更宽容。close_kernel只有第二步大核闭运算需要动参数从 (17,5) 往上调每组数字会更倾向于粘成一块往下调则更容易断裂。2.4 找到卡片四边形面积排序与多边形逼近卡片本身是画面里最大、最接近整块的浅色区域。找卡片不需要刚才那套梯度链路直接对灰度图做 Otsu再用大核闭运算把卡面上的深色文字和图案填平剩下的最大轮廓就是候选卡片。def detect_card_quad(gray, canvas_width800): # 缩放到统一宽度让轮廓过滤参数与分辨率解耦 scale canvas_width / gray.shape[1] gray cv2.resize(gray, (canvas_width, int(gray.shape[0] * scale))) _, thresh cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY cv2.THRESH_OTSU) # 大核闭运算填平卡面上的文字和图案 kernel cv2.getStructuringElement(cv2.MORPH_RECT, (21, 21)) closed cv2.morphologyEx(thresh, cv2.MORPH_CLOSE, kernel) contours, _ cv2.findContours(closed, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) big max(contours, keycv2.contourArea) peri cv2.arcLength(big, True) approx cv2.approxPolyDP(big, 0.02 * peri, True) return approx.reshape(4, 2).astype(float32) if len(approx) 4 else None闭运算核取 21×21 是为了把卡号、姓名这类深色块全部填成白色。核太小卡片轮廓会碎成好几块核太大会把背景也并进来。approxPolyDP的 0.02 倍周长作为逼近精度是四点检测里的常用起点逼近结果不是 4 个点时返回 None由调用方决定跳过矫正还是换检测策略。注意当卡片完全充满画面时最大轮廓就是图像边框检测会失败。比较省事的做法是拍摄时让卡片周围留出深色背景既利于二值化也让四点检测天然可用。3. 透视矫正与数字区域提取四点排序、轮廓过滤与分组切分拿到卡片的四个角点之后先把卡片扶正再在正视图上定位卡号。这一章的三步——四点排序、分组定位、单字符切分——直接决定识别结果是否正确因为模板匹配只能保证在输入规范时给出正确数字而输入是否规范完全由本章决定。3.1 四点透视变换坐标排序里的 sum 与 diff 陷阱approxPolyDP给出的四个点不保证顺序直接塞给getPerspectiveTransform输出大概率是一张拧巴的图。靠坐标之和与差就能完成排序左上角 xy 最小右下角 xy 最大右上角 y−x 最小左下角 y−x 最大。def order_points(pts): s pts.sum(axis1) # x y d pts[:, 1] - pts[:, 0] # y - x tl pts[np.argmin(s)] br pts[np.argmax(s)] tr pts[np.argmin(d)] bl pts[np.argmax(d)] return np.array([tl, tr, br, bl], dtypefloat32) def four_point_transform(image, pts): rect order_points(pts) tl, tr, br, bl rect # 上下两边的宽度、左右两边的高度各取最大值 width int(max(np.linalg.norm(br - bl), np.linalg.norm(tr - tl))) height int(max(np.linalg.norm(tr - br), np.linalg.norm(tl - bl))) dst np.array([[0, 0], [width - 1, 0], [width - 1, height - 1], [0, height - 1]], dtypefloat32) M cv2.getPerspectiveTransform(rect, dst) return cv2.warpPerspective(image, M, (width, height))d pts[:, 1] - pts[:, 0]得到的是 y−x不是 x−y方向反了左上和右下就会对调。目标矩形的宽取上下两边的较大值、高取左右两边的较大值是为了让短边不被压缩保留原始像素信息。getPerspectiveTransform估计一个 3×3 单应矩阵warpPerspective用它对整张图重投影——这就是计算机视觉中的透视几何在这类项目里最直接的落地。另一种常见做法是用cv2.minAreaRect拿旋转矩形再旋转校正但它假设卡片四边完整可见斜拍角度一大四个角的透视缩短会让校正后的长宽比失真。四点透视变换估计的是平面之间的单应关系能把任意四边形拉回矩形这也是它在固定版式识别里被选为首选的原因。3.2 分组定位卡号闭运算核宽度与长宽比过滤矫正后的卡片是正视图但光照条件没变所以定位卡号组要重跑一遍 2.3 的链路。这里真正需要调的只有大核闭运算的宽度核太窄4 个数字连不成一块核太宽有效期和持卡人姓名区域也会被并进来后续过滤会很痛苦。def locate_digit_groups(warped_gray, min_h20, ratio(2.5, 4.5)): # 复用 2.3 的链路得到分组二值图 binary digit_group_binary(warped_gray, close_kernel(17, 5)) contours, _ cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) groups [] for c in contours: x, y, w, h cv2.boundingRect(c) if h min_h: continue # 高度太小多半是噪点 aspect w / float(h) if ratio[0] aspect ratio[1]: if cv2.contourArea(c) 500: # 面积下限过滤切图碎片 groups.append((x, y, w, h)) return sorted(groups, keylambda g: g[0]), binary4 位数字组在矫正后的正视图里宽度一般是高度的 3 到 4 倍窗口取 2.5 到 4.5 能容忍字体粗细和字间距的差异。不同卡组织的布局不一样过滤参数要跟着卡类型走卡组织卡号布局闭运算核宽度参考长宽比上界Visa / Mastercard4-4-4-4(17,5)4.5American Express4-6-5(21,5)5.5美国运通卡的 6 位组比例接近 5上界要放宽到 5.5或者按卡 BIN 选参数。min_h20和面积下限 500 都是相对 300 到 500 像素高的卡片定的换分辨率要按比例重调。返回的 groups 按 x 排序x 的顺序就是卡号从左到右的顺序。3.3 单字符切分组内轮廓二次提取与 ROI 留白每组边界框里是 4 个粘连的数字。切分在二值图上进行背景纹理已经不在了轮廓就是字符本身逐个提取即可。唯一要注意的是 ROI 四周留边笔画一旦被裁到边界模板匹配分数会明显下降。def extract_digits(binary, group, pad5): x, y, w, h group region binary[y:y h, x:x w] contours, _ cv2.findContours(region, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) boxes [] for c in contours: dx, dy, dw, dh cv2.boundingRect(c) if dw 10 or dh 15: # 过滤噪点 continue boxes.append((dx, dy, dw, dh)) digits [] for dx, dy, dw, dh in sorted(boxes, keylambda b: b[0]): sx max(x dx - pad, 0) sy max(y dy - pad, 0) ex min(x dx dw pad, binary.shape[1]) ey min(y dy dh pad, binary.shape[0]) digits.append(binary[sy:ey, sx:ex]) return digitsdw和dh的过滤阈值要跟着卡片在画面中的尺寸走这套参数以卡面宽度约 800 像素为基准。留白统一 5 像素max/min夹取是为了防止靠近图像边缘时下标越界。如果某个字符在二值图里断成两段先用 2×2 膨胀核桥接再做轮廓检测如果断裂发生在竖笔画上回看 Sobel 和顶帽两步的中间结果多半是光照方向造成的笔画残缺。4. 用 matchTemplate 完成信用卡数字识别模式选型与置信度输出字符切好之后识别反而是整个项目里最短的一段代码。把每个字符和十个模板逐一匹配取最高分再决定它是 0 到 9 里的哪个数字。这一章把匹配主流程、六个模式的选型、以及结果如何串成卡号讲完整。4.1 识别主流程把字符 ROI 与十个模板逐一匹配matchTemplate要求模板不能比搜索图大。这里的模板统一 57×88所以字符 ROI 在匹配前也要 resize 成 57×88。此时匹配窗口只有一个位置matchTemplate退化成一次逐像素的相关计算minMaxLoc取出的就是这组比较的总分。这个项目快就是把问题提前归一化成了固定尺寸。def recognize_digits(digit_rois, templates, threshold0.70): results [] for roi in digit_rois: if roi.size 0: results.append((-1, 0.0)) continue roi cv2.resize(roi, (57, 88), interpolationcv2.INTER_AREA) scores {} for digit, template in templates.items(): score_map cv2.matchTemplate(roi, template, cv2.TM_CCOEFF_NORMED) _, max_val, _, _ cv2.minMaxLoc(score_map) scores[digit] max_val best max(scores, keyscores.get) results.append((best if scores[best] threshold else -1, scores[best])) return results每个字符和十个模板各比一次分数存进字典最后取最大值对应的键。threshold0.70是保守起点实际调试时把每字符的最高分和次高分打出来就能看到 0 和 8、1 和 7 这类近似字符的分数差通常有多大。如果所有分数普遍低于 0.7优先怀疑预处理而不是去调阈值。如果卡片分辨率很高切出来的字符 ROI 远大于 57×88直接缩小会损失判别细节。可以把 ROI 按比例缩到高为 88再在宽度方向居中补边到 57让字符在模板里的占位比例一致。模板里的数字本身也应保持在 57×88 内居中缩放过后的字符和模板若不对齐相关系数会系统性偏低。提示resize 缩小时用INTER_AREA放大时用INTER_LINEAR。反过来用会把细笔画插出锯齿匹配分数会掉一截。4.2 matchTemplate 六种匹配模式速查与选型匹配模式数值含义最优方向本项目建议TM_SQDIFF平方差越小越好不推荐光照敏感TM_SQDIFF_NORMED归一化平方差越小越好可用但阈值方向反直觉TM_CCORR相关值越大越好不推荐亮区虚高TM_CCORR_NORMED归一化相关越大越好凑合TM_CCOEFF相关系数去均值越大越好可用TM_CCOEFF_NORMED归一化相关系数越大越好首选CCOEFF 系列在计算前分别减去模板和搜索图的均值相当于把整体亮度偏移先消掉再做相关NORMED 后缀再把分数压到 [-1,1]阈值可以跨图像复用。SQDIFF 数值越小越好第一次用的人容易在取最大还是取最小上翻车。CCORR 对纯色区域会给出虚高的相关值在光照复杂的卡面上很容易选错模板。4.3 分组串接、置信度标记与结果绘制四组字符识别完之后按组坐标的 x 顺序串起来就是完整卡号。串接时保留分组空格既方便人眼核对也不影响后续的 Luhn 校验。低置信度的字符不要硬猜占位成问号让下游决定是重拍还是人工介入。def assemble(warped, groups, results_by_group): output warped.copy() parts [] for (x, y, w, h), res in zip(groups, results_by_group): text .join(str(d) if d 0 else ? for d, _ in res) parts.append(text) cv2.rectangle(output, (x, y), (x w, y h), (0, 255, 0), 2) cv2.putText(output, text, (x, y - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 0), 2) return output, .join(parts)zip(groups, results_by_group)的前提是两组列表长度一致。分组阶段漏了一个组、切分阶段多切一个字符都会让 zip 提前结束或者错位结果里出现只剩三组或某个组多了个数字。这类问题不会暴露在匹配分数里只能靠把组边界框画出来的方式检查。边界框画出来之后肉眼核对四件事组数是不是四个、每组框是否精确包住四个数字、框内有没有混入第二行的文字、组间距是否明显大于字间距。这四件事全对才轮到看识别结果。很多人在组合函数里 debug 半天最后发现是分组阶段把有效期当成了卡号组。5. 验证与排错合成样本回归、Luhn 校验与中间结果可视化5.1 合成样本回归不依赖真实标注的快速验证没有真实卡图时先用合成样本把流水线跑通。用cv2.putText按 4-4-4-4 布局画出 16 位数字再叠加旋转、高斯模糊和亮度偏移生成几十张回归样本。这类样本能暴露大部分管线 bug坐标排序写反、闭运算核宽度不对、切分越界全都在真实标注之前就会被拦住。def make_sample(number4000123456789010, angle0, sigma1.0): canvas np.full((480, 800), 40, dtypenp.uint8) blocks .join(number[i:i4] for i in range(0, 16, 4)) cv2.putText(canvas, blocks, (100, 260), cv2.FONT_HERSHEY_SIMPLEX, 2.0, 240, 4, cv2.LINE_AA) if angle: M cv2.getRotationMatrix2D((400, 240), angle, 1.0) canvas cv2.warpAffine(canvas, M, (800, 480)) return cv2.GaussianBlur(canvas, (3, 3), sigma)提示合成样本的字体必须和模板同源。cv2.putText的 Hershey 字体只适合验证流程如果模板是真实信用卡字体合成图也要用同一字体渲染否则测出来的差异来自字体而不是管线。5.2 Luhn 校验让卡号自带的校验位兜底16 位卡号的最后一位是 Luhn 校验位。识别完立刻跑一遍 Luhn单字符识别错一个也能被拦下来这比人工盯图快得多。def luhn_check(number): digits [int(c) for c in number if c.isdigit()] if len(digits) ! 16: return False s 0 for i, d in enumerate(digits): if i % 2 0: # 从左侧第 0 位起隔位翻倍 d d * 2 if d 9: d - 9 s d return s % 10 0偶数位翻倍后减 9相当于把两位数折叠成个位。校验不通过时把结果标记为待人工复核不要直接丢弃因为制卡错误虽然罕见但存在。Luhn 不增加识别召回它的价值是给看起来完整的结果加一道事实核查。5.3 中间结果可视化的三个排错习惯第一个习惯是把每个阶段的图并排存下来。np.hstack把灰度图、顶帽结果、Sobel 结果、二值图拼成长图哪一步把字符弄丢了一眼可见。多数识别失败发生在预处理而不是匹配阶段先看中间结果再怀疑算法。第二个习惯是打印每个字符 Top-2 分数。0 和 8、1 和 7 字形相近如果最高分和次高分差不到 0.05多半是切分裁掉了笔画而不是匹配本身的问题。第三个习惯是确认 OpenCV 的版本行为。cv2.findContours在 OpenCV 4 返回两个值OpenCV 3 返回三个值新版解包写成contours, _ cv2.findContours(...)就对了。如果import cv2报ModuleNotFoundError: No module named cv2先查 python 解释器和 pip 是不是同一个环境再执行pip install opencv-python最后用cv2.__version__验证安装。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

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