恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
OpenCV实战:信用卡数字识别与模板匹配全流程解析
首页
资讯中心
/
OpenCV实战:信用卡数字识别与模板匹配全流程解析
OpenCV实战:信用卡数字识别与模板匹配全流程解析
发布时间:2026/10/14 7:03:19
先说句实在话这两年凡是学OpenCV的朋友基本都会拿“信用卡数字识别”当练手项目。这个项目表面上是做OCR实际上是把灰度变换、形态学操作、轮廓检测、模板匹配这些OpenCV的底层能力一次性串起来。我当时跑通第一版的时候最大的感受不是“识别出几个数字”而是以前看文档觉得枯燥的API突然都活过来了。如果你已经有OpenCV基础又不想一上来就碰深度学习和显卡这个项目是非常合适的进阶实战题它不依赖训练数据不需要昂贵硬件一台普通电脑加Python环境就能跑而且做完之后你对“传统图像处理解决实际问题”的理解会扎实很多。项目本身解决的核心问题很简单给定一张信用卡样张图片自动识别出卡号印制的凸印数字并输出文本。注意这里的“信用卡”在实战中都得换成模拟样张或者自己生成的测试图别拿真实卡照片去跑一个是隐私问题另一个是真实卡片反光、背景干扰严重预处理要做好几轮调试。下面我用这套经典方案把整个从思路到代码的流程完整过一遍。1. 项目核心思路拆解1.1 这个项目到底在做什么信用卡卡号通常不是普通打印体而是凸印在卡片表面的立体数字。这类数字在侧面光照下会呈现明显的“隆起”效果边缘带有阴影和反光。如果直接把图片灰度化然后做二值化效果往往很差因为数字和背景的灰度差异不大光照还不均匀。所以这个项目的第一难点不是识别而是怎么把数字区域从复杂的背景中稳定地“挖”出来。整套流程可以概括成一句话先用预处理把数字区域变成清晰的白色块再用轮廓检测把每一组数字框出来最后用模板匹配逐个比对数字。这个过程没有用任何机器学习模型依赖的全是图像本身的几何和灰度特性这也是我说它适合练基础的原因。你会在里面反复用到形态学操作里的顶帽、闭运算以及Sobel梯度、Otsu阈值、轮廓排序这些内容做完一遍基本等于把OpenCV的常规操作复习了两轮。1.2 技术方案选型为什么没用深度学习现在做字符识别很多人第一反应就是CNN或者OCR框架比如PaddleOCR、Tesseract。诚然深度学习方案在复杂场景下准确率更高但作为入门项目它的缺点也明显环境依赖重、模型体积大、训练需要标注数据而且出问题时你很难定位是模型不好还是预处理不好。模板匹配方案的优点是可控和透明。数字就0到9一共10种模板我可以直接裁剪出来匹配过程也能可视化验证。在信用卡这种相对固定的场景下模板匹配的精度完全够用。我对这个选型挺认可的因为它的核心价值不在于“识别率比深度学习高”而在于把整个图像处理管线完整地讲明白了。等你把这个项目吃透以后真要换深度学习方案你至少知道前置的图像预处理应该做到什么程度模型输入应该长什么样。1.3 整体处理流程一览为了方便调试我把整个流程分成三个大阶段预处理阶段读取图片、灰度化、顶帽运算突出凸印结构、Sobel梯度强化边缘、闭运算把离散数字连成组、Otsu阈值得到二值图。区域定位阶段在二值图上找轮廓按宽高比和高度筛选出“数字组”区域再对每组区域内部做数字切割得到单个数字的小图。识别阶段预先做一套0-9的参考模板把每个数字小图缩放到模板大小用归一化相关性模板匹配取分数最高的数字作为识别结果。这三个阶段对应着三类不同的调试手段预处理阶段看中间图区域定位阶段看是否框准识别阶段看匹配分数。下面我一步步拆开讲每一步的参数我都会给具体值但也会说明这些值为什么这么定。2. 开发环境准备与图像预处理2.1 环境搭建与依赖版本开发环境我用的是Windows PyCharm Python 3.9 OpenCV 4.5。PyCharm就正常建一个新的纯Python项目就行OpenCV用pip安装即可pip install opencv-python opencv-contrib-python numpy这里有个小建议如果只是做传统图像处理opencv-python就够用了不需要装opencv-contrib-python。但如果你后续可能用SIFT、ORB这些特征点算法那contrib版本更方便省得以后装两遍。numpy是OpenCV的底层依赖做数组运算时会直接用到所以显式装一下最保险。安装完之后可以在PyCharm里先跑一个一两行的脚本验证环境import cv2 print(cv2.__version__)如果能打印出版本号说明环境没问题。常见的问题是PyCharm解释器没切到当前虚拟环境导致import cv2报错这个在PyCharm右下角切换一下解释器就行。2.2 图像预处理的四个关键操作预处理是整个项目的灵魂很多新手一上来就找轮廓失败绝大多数是预处理没做到位。我的预处理顺序是灰度化 → 顶帽运算 → Sobel梯度 → 闭运算 → 二值化。我直接给出每一步的代码和参数# 读取图像转换为灰度 image cv2.imread(images/credit_card.png) image cv2.resize(image, (600, int(image.shape[0] * 600 / image.shape[1]))) gray cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) # 1. 顶帽运算突出比背景更亮的小结构 rect_kernel cv2.getStructuringElement(cv2.MORPH_RECT, (9, 5)) tophat cv2.morphologyEx(gray, cv2.MORPH_TOPHAT, rect_kernel) # 2. Sobel梯度强化数字边缘 grad_x cv2.Sobel(tophat, ddepthcv2.CV_32F, dx1, dy0, ksize-1) grad_x np.abs(grad_x) min_val, max_val np.min(grad_x), np.max(grad_x) grad_x ((grad_x - min_val) / (max_val - min_val) * 255).astype(uint8) # 3. 闭运算把数字连接成整体 rect_kernel cv2.getStructuringElement(cv2.MORPH_RECT, (21, 5)) closed cv2.morphologyEx(grad_x, cv2.MORPH_CLOSE, rect_kernel) # 4. 二值化 _, thresh cv2.threshold(closed, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU)这里有几个参数需要解释。顶帽运算的核用了(9, 5)这个尺寸要略大于单个数字的笔画宽度太小会把噪声也放大太大又会把数字和背景一起过滤掉。Sobel用ksize-1表示使用Scharr算子它对边缘的响应比普通Sobel更细致适合这种数字边界柔和的情况。闭运算的核(21, 5)特意做宽做扁是因为卡号数字是水平排列的宽核能横向把相邻数字连成一个区域纵向又不会把上下行的干扰连进来。二值化用Otsu而不是固定阈值是因为输入图像的明暗不固定Otsu可以根据灰度分布自动算阈值适应性更强。2.3 预处理每一步都在解决什么问题我见过很多人直接跳过顶帽运算觉得灰度化之后二值化不就行了吗这里我实际对比过不做顶帽直接用Sobel数字边缘会被背景纹理和卡片图案干扰尤其当卡面有花纹或Logo时轮廓检测会把那些非数字区域也框进来。顶帽运算的本质是用原图减去开运算结果开运算会滤掉小于核尺寸的亮结构所以顶帽保留的是“亮度明显高于局部背景”的部分。凸印数字的隆起区域在光照下恰好符合这个特征所以它能把数字从背景中分离出来。Sobel这一步的作用是把顶帽结果中的数字边缘强化成连续的亮线。计算完成后要注意Sobel输出的数值可能超出0-255范围所以先取绝对值再手动归一化到8位图否则后面阈值处理会用到一个错误的图像。闭运算则是关键中的关键它把相邻数字之间的空隙填上让“一组数字”变成一个连通块这样后续找轮廓时一个轮廓就对应一组卡号数字而不是一个数字一个轮廓。最后二值化输出黑白图为找轮廓做直接准备。3. 参考模板制作与数字区域定位3.1 制作0-9数字模板模板匹配需要一套标准数字图像。我通常从一张排版规则的模板图里截取0到9十个数字然后统一缩放到固定尺寸存成字典供后面调用。模板图一般是白底黑字每个数字之间有一定间距。def build_template_dict(template_path): template_img cv2.imread(template_path) template_gray cv2.cvtColor(template_img, cv2.COLOR_BGR2GRAY) _, template_bin cv2.threshold(template_gray, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU) contours, _ cv2.findContours(template_bin.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) sorted_contours sorted(contours, keylambda c: cv2.boundingRect(c)[0]) template_dict {} for i, cnt in enumerate(sorted_contours): x, y, w, h cv2.boundingRect(cnt) digit_roi template_bin[y:y h, x:x w] digit_roi cv2.resize(digit_roi, (57, 88)) template_dict[i] digit_roi # 按从左到右顺序对应0-9 return template_dict这里有一个细节非常容易踩坑模板图如果是从某个页面截取的最左边不一定是从0开始的。有些模板图会在第一个数字左侧放一个Logo或标记这样enumerate之后索引0对应的就不是数字0了。我的处理方式是先手工看一眼排序结果如果发现顺序不对就改一下排序逻辑或者把模板图先裁剪成只包含0-9的区域再传入这个函数。你还可以在函数里加一行打印把每个轮廓的x坐标输出对不上就手动调整。模板尺寸(57, 88)不是拍脑袋定的它来自对原图中单个数字高度的估算。信用卡数字区域的高度大约是30像素左右单个数字宽度约20像素但模板图里的数字分辨率可能更高所以统一缩放到57x88是为了后续用matchTemplate时保持维度一致。如果你用的测试图分辨率不同这个尺寸也不用改因为后面识别时会把待识别数字也缩放到这个大小保证模板和输入尺寸统一即可。3.2 从信用卡图像中定位数字区域模板做完以后回到预处理得到的二值图上下一步就是找出所有可能包含数字的区域。直接findContours之后轮廓会非常多有卡面Logo、有装饰条纹、可能还有文字。我筛选轮廓时用两个硬条件宽高比和高度。contours, _ cv2.findContours(thresh.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) locations [] for cnt in contours: x, y, w, h cv2.boundingRect(cnt) ar w / float(h) # 信用卡卡号一组通常有4个数字宽高比大约在2.5-4.5之间 # 高度需要大于一个阈值排除小噪点 if ar 2.5 and ar 4.5 and h 15: locations.append((x, y, w, h))为什么宽高比要设成2.5到4.5因为一组卡号一般是4个数字横向排列每个数字之间还可能有些微间距框住整组后的宽度和高度比大概就是三到四倍。如果测试图是3个数字一组比如卡号分组的后几位宽高比会低一些这时候可以把ar 2.0放开。高度阈值设成15是为了排除小面积噪声比如卡面上的小圆点、纹理碎片。这个值取决于你的图像缩放尺寸我前面把图片宽度resize到了600这个阈值是合适的。如果你发现筛选出来的区域数量不对可以把筛选条件放宽一点然后通过绘制轮廓看检测结果我一般会加一段临时代码for (x, y, w, h) in locations: cv2.rectangle(image, (x, y), (x w, y h), (0, 255, 0), 2)这一步能直观看到哪些区域被选中了。我实际调试时发现信用卡左上角的芯片区域有时也会满足宽高比条件因为芯片也是扁长的矩形。如果芯片被误检一般是靠高度排除或者加一个自定义规则比如区域位置不能出现在左上角。这个根据你的测试图来微调就好。3.3 按“一组一组”切分数字区域得到每个数字组的包围盒之后就要从原图上把这个区域裁剪出来然后在这个区域内找出每个数字的轮廓按x坐标排序逐个送入识别。这一步分组逻辑不能省否则你会把所有数字混在一起模板匹配根本没法做。def extract_digit_groups(gray, locations): digit_groups [] for (x, y, w, h) in locations: group_roi gray[y:y h, x:x w] # 对组内做一次二值化确保数字与背景分离 _, group_bin cv2.threshold(group_roi, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU) digit_contours, _ cv2.findContours(group_bin.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) digit_contours sorted(digit_contours, keylambda c: cv2.boundingRect(c)[0]) digits [] for dcnt in digit_contours: dx, dy, dw, dh cv2.boundingRect(dcnt) if dh 5 and dw 3: # 过滤过小的杂点 digit_roi group_bin[dy:dy dh, dx:dx dw] digits.append(digit_roi) digit_groups.append(digits) return digit_groupsTHRESH_BINARY_INV这个参数很多人会忽略。凸印数字在信用卡上是凸起的光线不同可能显示为亮底暗字或暗底亮字。如果直接对灰度图做普通二值化数字可能是黑色、背景白色模板匹配阶段则要求数字是白色、背景黑色。所以我在组内二值化时用了取反模式这样无论原图数字是亮是暗经过Otsu自动计算后最终的group_bin里数字区域是白、背景是黑。如果你发现这个阶段数字是黑底白字匹配分数会非常低检查一下这里是不是用了THRESH_BINARY_INV。4. 识别实现与完整代码4.1 模板匹配原理与参数选择OpenCV的matchTemplate做的事情是把模板在输入图上滑动在每个位置计算模板与对应区域的相似度。常用方法有三种TM_SQDIFF平方差越小越好、TM_CCORR相关性越大越好、TM_CCOEFF_NORMED归一化相关系数越大越好。我推荐用TM_CCOEFF_NORMED因为它对光照和灰度偏移不敏感在数字这种笔画边缘明显的场景下最稳定。匹配之前需要把每个待识别数字resize到和模板一样的尺寸(57, 88)。这里有一个容易被忽视的坑如果你直接用cv2.resize(digit_roi, (57, 88))那么窄而高的数字比如1会被横向拉伸变形。模板图里的数字都是标准字体如果待识别图的宽高比和模板不一致拉伸后匹配分数会下降。我建议在resize之前先把数字用固定边框扩展到一个统一宽高比的范围def resize_to_template(digit_bin, target_h88, target_w57): h, w digit_bin.shape if h w: scale target_h / h new_w int(w * scale) if new_w target_w: resized cv2.resize(digit_bin, (target_w, target_h)) else: resized cv2.resize(digit_bin, (new_w, target_h)) diff target_w - new_w left diff // 2 right diff - left resized cv2.copyMakeBorder(resized, 0, 0, left, right, cv2.BORDER_CONSTANT, value0) else: scale target_w / w new_h int(h * scale) resized cv2.resize(digit_bin, (target_w, new_h)) diff target_h - new_h top diff // 2 bottom diff - top resized cv2.copyMakeBorder(resized, top, bottom, 0, 0, cv2.BORDER_CONSTANT, value0) return resized这个函数做的事情是先把数字等比缩放在目标区域内再用黑色边框补齐四周保证数字不会被拉变形。补边用的是BORDER_CONSTANT模式填充值为0黑色不会影响匹配计算。如果你懒直接用拉伸版的resize也能跑但遇到像“1”这种瘦长数字的时候识别错的概率会明显增加。实测下来等比缩放加补边的方案整体准确率能提升不少。4.2 信用卡数字识别完整代码把所有环节串起来我写下完整的主流程代码。代码结构分成三块模板构建、候选区域定位、逐组识别。你可以直接复制运行只要把图片路径改成自己的import cv2 import numpy as np def build_template_dict(template_path): template_img cv2.imread(template_path) template_gray cv2.cvtColor(template_img, cv2.COLOR_BGR2GRAY) _, template_bin cv2.threshold(template_gray, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU) contours, _ cv2.findContours(template_bin.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) sorted_contours sorted(contours, keylambda c: cv2.boundingRect(c)[0]) template_dict {} for i, cnt in enumerate(sorted_contours): x, y, w, h cv2.boundingRect(cnt) digit_roi template_bin[y:y h, x:x w] digit_roi cv2.resize(digit_roi, (57, 88)) template_dict[i] digit_roi return template_dict def resize_to_template(digit_bin, target_h88, target_w57): h, w digit_bin.shape if h w: scale target_h / h new_w int(w * scale) if new_w target_w: return cv2.resize(digit_bin, (target_w, target_h)) resized cv2.resize(digit_bin, (new_w, target_h)) diff target_w - new_w left diff // 2 right diff - left resized cv2.copyMakeBorder(resized, 0, 0, left, right, cv2.BORDER_CONSTANT, value0) return resized else: scale target_w / w new_h int(h * scale) if new_h target_h: return cv2.resize(digit_bin, (target_w, target_h)) resized cv2.resize(digit_bin, (target_w, new_h)) diff target_h - new_h top diff // 2 bottom diff - top resized cv2.copyMakeBorder(resized, top, bottom, 0, 0, cv2.BORDER_CONSTANT, value0) return resized def match_digit(digit_bin, template_dict): digit_resized resize_to_template(digit_bin) best_score -1 best_digit -1 for digit, template in template_dict.items(): result cv2.matchTemplate(digit_resized, template, cv2.TM_CCOEFF_NORMED) _, max_val, _, _ cv2.minMaxLoc(result) if max_val best_score: best_score max_val best_digit digit return best_digit, best_score def recognize_card_number(image_path, template_path): template_dict build_template_dict(template_path) image cv2.imread(image_path) image cv2.resize(image, (600, int(image.shape[0] * 600 / image.shape[1]))) gray cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) # 预处理 rect_kernel cv2.getStructuringElement(cv2.MORPH_RECT, (9, 5)) tophat cv2.morphologyEx(gray, cv2.MORPH_TOPHAT, rect_kernel) grad_x cv2.Sobel(tophat, ddepthcv2.CV_32F, dx1, dy0, ksize-1) grad_x np.abs(grad_x) min_val, max_val np.min(grad_x), np.max(grad_x) grad_x ((grad_x - min_val) / (max_val - min_val) * 255).astype(uint8) rect_kernel cv2.getStructuringElement(cv2.MORPH_RECT, (21, 5)) closed cv2.morphologyEx(grad_x, cv2.MORPH_CLOSE, rect_kernel) _, thresh cv2.threshold(closed, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU) # 定位数字组 contours, _ cv2.findContours(thresh.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) locations [] for cnt in contours: x, y, w, h cv2.boundingRect(cnt) ar w / float(h) if 2.5 ar 4.5 and h 15: locations.append((x, y, w, h)) locations sorted(locations, keylambda loc: loc[0]) # 逐组识别 output [] for (x, y, w, h) in locations: group_roi gray[y:y h, x:x w] _, group_bin cv2.threshold(group_roi, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU) digit_contours, _ cv2.findContours(group_bin.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) digit_contours sorted(digit_contours, keylambda c: cv2.boundingRect(c)[0]) for dcnt in digit_contours: dx, dy, dw, dh cv2.boundingRect(dcnt) if dh 5 and dw 3: digit_roi group_bin[dy:dy dh, dx:dx dw] digit, score match_digit(digit_roi, template_dict) output.append(str(digit)) return .join(output) if __name__ __main__: result recognize_card_number(images/credit_card_sample.png, images/digit_template.png) print(识别结果:, result)这套主流程在经典的样例图上识别率能达到90%以上。实际效果取决于图像质量和预处理参数但思路是完全通用的。4.3 输出处理的细节识别结果以空格拼接输出是为了保留卡号分组信息。如果你希望得到连续的16位数字可以直接把空格去掉result_continuous result.replace( , )还有两个细节需要注意。第一输出顺序依赖两个排序预处理阶段得到的locations按x坐标排序组内数字轮廓也按x坐标排序。少了任何一个排序识别结果就会乱序因为轮廓检测返回的顺序是随机的不代表视觉上的左右顺序。第二match_digit返回的best_score是一个0到1之间的值我建议把它打印出来看看。如果某个数字的匹配分数低于0.5那大概率是预处理或者裁剪出了问题而不是模板的问题。我实际调试时会把中间结果保存到本地方便对比比如把每个digit_roi和对应的识别结果、分数存成一个列表最后一起打印。这样能直观看到哪些数字被认错了是模板问题还是切割问题避免了盲调参数。5. 常见问题与调试经验5.1 预处理失效的排查顺序如果你的代码跑完一个区域都没框出来那问题基本出在预处理。我的排查顺序是固定的先看顶帽结果图是否清晰显示出数字轮廓再看Sobel结果是否保留了必要边缘最后看闭运算后的图里数字是否连成了块。每看一步我都是用cv2.imshow直接显示中间结果。顶帽结果模糊把顶帽核调大一点比如从(9, 5)改成(11, 9)。闭运算后数字还是断裂的把闭运算核的宽度从21调大到31。注意一次只调一个参数否则你根本不知道是哪个变更生效了。我见过有人同时改了三个参数结果越调越差最后还是回到一个个试的老办法。5.2 匹配不准的常见原因匹配分数低或者识别错误我总结下来有三个高频原因。第一个是resize导致数字变形这个我在前面已经处理了。第二个是模板图和待识别图的字体不一致数字“4”的开口角度、数字“7”有无横杠不同字体差异很大。如果你的测试图字体和模板图明显不同匹配效果就是会差这时候要么找同字体的模板要么干脆自己生成模板。第三个是二值化模式不对待识别数字应该是白色、背景是黑色如果反了matchTemplate的分数自然很低。我在识别时还遇到过数字“0”和“8”互相认错的情况。这两个数字高度相似差异只在内部有没有闭合的空腔。这种问题靠模板匹配很难彻底解决因为笔画粗细稍有变化就会翻盘。我的弥补方式是加一个后验判断如果匹配分数非常接近再看一下数字内部白色像素的连通域数量0通常没有内部空洞8有两个这个规则简单粗暴但有效。5.3 换一张图就翻车通用性优化很多人在样例图上跑通了换一张自己拍的图就全军覆没。这个问题几乎都是因为预处理参数是写死的。原始样例图的光照、背景、卡片比例都是固定的你换了一张图原来的阈值和核尺寸就不一定适用。我的建议是程序里保留一个“调试模式”把所有的可调参数都提出来作为变量方便随时修改DEBUG True tophat_kernel (9, 5) close_kernel (21, 5) min_height 15 aspect_range (2.5, 4.5)然后每次换图先用调试模式看中间结果再调整参数。另外信用卡图像可能带旋转角度数字不是完全水平的。遇到这种情况我通常先做一个粗略的旋转矫正一般用最小外接矩形检测角度然后cv2.getRotationMatrix2D和cv2.warpAffine做矫正。这个功能可以作为后续扩展日常练习可以先不处理。5.4 关于训练数据那个“模板图”的准备模板匹配的准确率上限由模板图决定所以模板图质量很重要。我用的模板图是白底黑字的0-9数字字体要尽量接近信用卡凸印数字的字体。如果找不到合适的模板图可以自己做一个用画图工具在白纸上写0到9拍下来或用截图工具截取注意保持数字间距均等。做模板的时候不要用抗锯齿太重的图形因为边缘模糊会导致匹配分数普遍偏高区分度反而下降。模板图里数字的边界裁剪也很关键。cv2.findContours找外轮廓时如果数字周围有杂点会把杂点也算进外轮廓导致模板里多出一些噪声。我一般会在findContours之前对模板图先做一次膨胀稍微扩大边缘这样轮廓能把整个数字包住然后再从原始二值图里按这个扩大的包围盒裁剪。这个技巧能减少模板边缘不完整的问题。6. 一点扩展建议与个人心得如果你跑通了基础版本我建议你马上去做两件扩展。第一件是给程序加一个“可视化输出”把预处理每一步的图像和最终识别结果拼在一起输出为一个结果图。这看起来是小事但会让你对“每个环节解决什么问题”有更直观的认知。第二件是尝试把手动写死的参数改成自适应逻辑比如用图像分辨率比例来代替固定阈值用数字分组的数量变化来代替固定宽高比。这个项目扩展成“车牌号识别”“快递单号识别”其实是很自然的事情因为本质上都是定位切割识别的流程。最后分享一个我自己的体会这类传统视觉项目最容易被忽略的是中间结果的验证。不要一上来就追求直接输出最终答案而是每做完一步预处理、轮廓检测都停一下把结果可视化出来看看到底发生了什么。我做这个项目的时候至少有三分之一的时间是盯着中间图在找问题。但正是这种“慢”让我后来做任何视觉相关项目时遇到问题都能迅速定位到是哪一个环节出了岔子。这个能力比跑通一个项目的成就感更值钱。