OpenCV实战:硬币检测识别从边缘检测到模板匹配全流程解析 简介一份面向Python与OpenCV初学者的硬币检测与识别源码项目针对从实拍照片中识别硬币数量并判断总价值的典型任务完整梳理图像处理流程。包内共13个文件其中1个Python脚本实现核心算法12张JPG图片提供模板、多组测试样本及结果对比图整体体积仅7.39MB便于快速下载与本地实验。已有2253人浏览学习适用于计算机视觉课程设计、图像处理实验及OpenCV入门实践。代码涵盖灰度化、滤波、边缘检测、轮廓提取、模板匹配和结果可视化等环节配合样本可直观验证每一阶段输出领悟硬币定位、计数的实现思路替换自有图片亦可测试对掌握阈值分割、形态学处理与特征匹配有实用参考价值。同时源码附有基础注释便于初学者逐模块调试项目结构简洁可作为药片、纽扣等其他圆形物体计数任务的改造蓝本。1. 硬币检测与识别从一张照片到零钱统计把一堆散落在桌上的硬币拍张照程序自动数出有几枚、面值多少、总共多少钱——这就是这个 python-opencv 硬币检测与识别项目做的事。它不是训练一个深度学习模型去识别硬币而是用 OpenCV 的传统图像处理方案先通过边缘检测和霍夫圆变换把图像里的圆形物体找出来再从每个圆形区域里做模板匹配或特征比对判断它是哪种面值。适合两类人一类是刚学完 OpenCV 基础、想完整走一遍“图像预处理→特征提取→目标检测→分类识别”全流程的开发者另一类是手头有类似需求——比如检测圆形工件、药片、瓶盖之类——想拿硬币场景练手、然后移植过去的人。这个项目的价值在于整条检测链路只依赖 OpenCV不需要标注数据集不需要 GPU一台普通机器跑起来毫无压力。2. 技术选型与整体流程预处理、找圆、识别三段式链路2.1 为什么选 OpenCV 传统方案而不是深度学习检测硬币检测看似简单但真上手做会发现有两个难点第一是硬币与背景的区分第二是不同面值硬币之间的区分。如果用深度学习目标检测比如 YOLO 系列确实能同时解决“定位分类”两个问题但代价是要准备几百上千张标注好的硬币图片还要考虑训练时长、模型体积、部署环境。对于“识别桌面零钱”这种约束明确的场景传统视觉方案反而更划算。传统方案的核心假设是硬币是圆形、有比较明显的边缘、表面纹理相对固定。这个假设在绝大多数场景下成立所以可以通过几何特征先“找圆”再通过颜色、纹理或模板比对来“认面值”。整个过程不需要训练核心代码不超过两百行运行速度用毫秒计。这种“先定位后分类”的两段式思路也恰恰是很多工业视觉项目的常用套路。2.2 项目结构与检测流程一个合理的项目目录大概长这样coins_detector/ ├── images/ │ ├── test_coins.jpg │ └── scene_01.jpg ├── templates/ │ ├── one_yuan.jpg │ ├── five_jiao.jpg │ └── one_jiao.jpg ├── detect.py # 检测主入口找圆 标定面值 ├── recognize.py # ROI 切片 模板匹配 └── utils.py # 图像预处理辅助函数detect.py 是主入口负责读图、调用预处理、找圆、遍历每个圆调用 recognize.py 做面值识别最后把结果画在原图上并打印统计信息。recognize.py 负责把每个圆的 ROI 切出来、归一化尺寸再与模板做匹配。utils.py 放一些公共的预处理函数比如灰度化、高斯滤波、Canny 边缘检测。整体检测流程就是一条三段式链路图像预处理 → 找圆定位 → ROI 识别。预处理解决“图像太脏”的问题把噪声、光照不均的影响压下去找圆解决“硬币在哪”的问题ROI 识别解决“这是哪枚硬币”的问题。每一段都有独立的函数替换其中任何一段都不影响其他部分这也是为什么这个项目适合拿来练手和二次开发。2.3 主流程代码骨架先把 detect.py 的整体结构摆出来后续每一段再做细讲。主流程是这样的import cv2 import numpy as np from utils import preprocess_image from recognize import recognize_coin def main(image_path): # 1. 读图与预处理 img cv2.imread(image_path) processed preprocess_image(img) # 2. 霍夫圆变换找圆 circles cv2.HoughCircles(processed, cv2.HOUGH_GRADIENT, dp1.2, minDist80, param1100, param260, minRadius40, maxRadius200) if circles is None: print(未检测到硬币) return # 3. 遍历每个圆做 ROI 识别 results [] for (x, y, r) in circles[0]: label recognize_coin(img, (int(x), int(y), int(r))) results.append((int(x), int(y), int(r), label)) # 4. 可视化与统计 for (x, y, r, label) in results: cv2.circle(img, (x, y), r, (0, 255, 0), 2) cv2.putText(img, label, (x - 20, y - 20), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 255), 2) cv2.imwrite(result.jpg, img) print(f共检测到 {len(results)} 枚硬币)HoughCircles 是核心检测函数几个参数直接影响检出质量dp 是累加器分辨率与图像分辨率的比值一般固定 1.2 左右minDist 是圆心之间的最小距离必须大于硬币半径否则同一枚硬币可能被重复检出param1 和 param2 是 Canny 边缘检测和圆心累加器的阈值param2 调得越小越容易检出弱边缘圆但也更容易误检。这些参数的调试经验后面单独讲。3. 核心模块拆解边缘检测与霍夫圆变换的代码实战3.1 图像预处理先给图像“洗个脸”硬币照片拍出来通常有环境光干扰、阴影、背景纹理杂乱比如木桌纹理、纸张图案。如果直接拿原始图做边缘检测Canny 会把你不想看到的纹理也当成边缘霍夫圆变换的误检率会直线上升。所以预处理这一步不能省。常见的预处理流程是三步灰度化 → 高斯滤波 → 形态学操作。灰度化是为了减少计算量通道从三通道变成单通道高斯滤波是为了抑制高频噪声让边缘变得更“干净”形态学操作用来填补硬币表面因反光产生的空洞或者消除背景里的细小斑点。import cv2 import numpy as np def preprocess_image(img): # 灰度化三通道转单通道计算量直接降到三分之一 gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 高斯滤波ksize 越大越模糊5x5 是常用值 blurred cv2.GaussianBlur(gray, (5, 5), 0) # 自适应阈值增强前景与背景的对比 thresh cv2.adaptiveThreshold( blurred, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 15, 4 ) # 开运算先腐蚀后膨胀去掉背景里的细小噪点 kernel cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)) opened cv2.morphologyEx(thresh, cv2.MORPH_OPEN, kernel) return opened这里用到了 adaptiveThreshold 而不是全局阈值原因在于硬币照片经常出现局部光照不均一边亮一边暗全局阈值要么把暗处的硬币切掉要么把亮处的背景误认为前景。自适应阈值会按局部邻域计算阈值对光照变化比全局阈值要稳得多。参数 blockSize15 表示每个像素的阈值由周围 15x15 邻域决定C4 是一个经验偏移量表示从计算结果里减去的常数调大一点可以抑制微弱噪声。预处理完的效果是背景变成黑色硬币主体变成白色圆形区域。这种二值图做霍夫圆变换或者轮廓提取都非常干净。3.2 Canny 边缘检测找到圆形边界如果走轮廓检测路线Canny 是必经一步。它把灰度图转换成一张只保留“亮度突变”的边缘图是找圆形轮廓的前提。Canny 的两个阈值minVal 和 maxVal是调参重点阈值设太高硬币边缘会断成一段段的设太低背景纹理会被当成边缘。def detect_edges(opened): # opened 已经是二值图直接转成 uint8 再做 Canny edges cv2.Canny(opened, 50, 150) # 膨胀边缘把断裂的边补起来 kernel np.ones((3, 3), np.uint8) edges cv2.dilate(edges, kernel, iterations1) return edgesCanny 的 50 和 150 分别是低阈值和高阈值。边缘点的梯度值大于 150 才被认为是强边缘大于 50 且与强边缘相连的被认为是弱边缘。这个项目里硬币边缘是图像中最强的边缘之一所以 50/150 这个区间基本够用。如果图像分辨率很高硬币边缘看起来比较柔和可以把两个阈值同时往下调比如 40/120让更多像素点进入候选。dilate 膨胀这一步很多人会忽略但实际检测中非常关键。Canny 出来的硬币边缘往往存在零点几像素的断裂膨胀操作可以把断开的边缘“焊”起来方便后续 findContours 提取到完整的闭合轮廓。3.3 findContours 提取轮廓硬币的几何属性从这里来有了干净的边缘图下一步是用 findContours 提取轮廓。轮廓的意义不只是拿到一个点集更重要的是能直接算出面积、周长、外接圆半径这些几何属性这些属性一方面可以筛掉非圆噪声另一方面可以作为面值分类的可选特征。def find_circles_by_contours(edges): contours, _ cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) circles [] for cnt in contours: # 用最小外接圆拟合轮廓 (x, y), radius cv2.minEnclosingCircle(cnt) area cv2.contourArea(cnt) circle_area np.pi * radius * radius # 面积占比接近 1说明轮廓形状接近圆 if area / circle_area 0.75 and radius 30: circles.append((int(x), int(y), int(radius))) return circles这里用了一个判定圆的经典技巧算轮廓面积和最小外接圆面积的比值。如果轮廓是圆形轮廓面积应该约等于外接圆面积比值接近 1。一个正方形轮廓的占比大约是 0.64一段直线几乎为 0。通过 area / circle_area 0.75 这个阈值能过滤掉大部分非圆轮廓。RETR_EXTERNAL 表示只提取最外层的轮廓硬币内部的花纹轮廓不会被提取避免干扰。CHAIN_APPROX_SIMPLE 是轮廓压缩模式只保留端点减少内存占用。3.4 两种找圆方式对比findContours 与 HoughCircles上面给出了两种找圆路径findContours minEnclosingCircle以及 HoughCircles 直接找圆。两者各有适用场景不是非此即彼。对比项findContours 外接圆HoughCircles前置要求边缘连续闭合边缘不要求完全闭合抗噪能力依赖面积占比过滤依赖 param2 阈值速度快相对慢累加器开销重叠硬币无法分开minDist 可控制参数复杂度低只需面积阈值高5 个参数都要调实际做这个项目时如果硬币摆得比较分散、没有重叠我会首选 HoughCircles因为它在硬币边缘不完整比如被阴影切掉一块时也能识别出圆。如果硬币有明显重叠HoughCircles 的 minDist 参数会很有用——它能限制圆心之间最小距离避免把一枚硬币检测成多个圆。findContours 更适合作为 HoughCircles 的校验手段两者结果交叉验证能有效降低误检率。4. 核心模块拆解ROI 归一化与模板匹配识别面值4.1 ROI 切片把每枚硬币单独抠出来找圆完成之后每个硬币的位置和半径都已知。接下来要把每个圆形区域从原图里切出来分别做面值识别。这一步听起来简单但有个细节容易忽略直接按矩形从原图切片会把圆以外的背景也带进来这些背景信息在后续模板匹配或特征提取时会成为干扰项。def extract_roi(img, x, y, radius): # 向外扩一点再切避免边缘处被硬切 padding radius // 5 x1 max(0, x - radius - padding) y1 max(0, y - radius - padding) x2 min(img.shape[1], x radius padding) y2 min(img.shape[0], y radius padding) roi img[y1:y2, x1:x2] # 生成圆形掩码把圆外区域涂黑 mask np.zeros(roi.shape[:2], dtypenp.uint8) center (roi.shape[1] // 2, roi.shape[0] // 2) cv2.circle(mask, center, radius, 255, -1) masked cv2.bitwise_and(roi, roi, maskmask) return maskedpadding 是为了防止圆形边缘紧贴 ROI 边界导致信息丢失radius // 5 是常用的经验值。生成圆形掩码这一步很关键它把矩形 ROI 中圆以外的背景像素全部置为黑色这样后续无论是模板匹配还是直方图统计参与计算的只有硬币本身。如果省掉掩码背景里的文字、桌面纹理都会参与特征计算识别准确率会被直接影响。4.2 模板匹配最简单的面值分类方案面值识别的第一步对新手最友好的方法是模板匹配。原理非常直白提前给每个面值拍一张标准的正面照作为模板识别时把待识别 ROI 缩放到和模板一样的尺寸逐一做相似度比对相似度最高的那个模板就是识别结果。它不需要训练只需要保证模板和待识别图像的面值外观一致。import cv2 import os TEMPLATES_DIR templates TEMPLATE_NAMES [one_yuan, five_jiao, one_jiao] def recognize_coin(img, circle): x, y, radius circle roi extract_roi(img, x, y, radius) # 统一尺寸模板和 ROI 都缩放到 128x128 std_size (128, 128) roi_resized cv2.resize(roi, std_size) best_score -1 best_label unknown for name in TEMPLATE_NAMES: tmpl cv2.imread(os.path.join(TEMPLATES_DIR, name .jpg)) tmpl_resized cv2.resize(tmpl, std_size) # 比较相似度 score compare_similarity(roi_resized, tmpl_resized) if score best_score: best_score score best_label name return best_label if best_score 0.5 else unknown模板匹配的成败90% 取决于 ROI 与模板在拍摄条件下的对齐程度。如果模板是在白色背景、均匀光照下拍的而实际检测图是黄色灯光下拍的那 128x128 的尺寸统一并不能弥补亮度差异。所以比较相似度时不能直接用像素相减必须用归一化的度量方式。4.3 相似度计算也谈归一化交叉相关相似度比较方式直接影响识别准确率。直接比较像素值对光照太敏感我一般用归一化交叉相关或直方图相似度。归一化交叉相关的好处是它对整体亮度变化有不错的鲁棒性直方图相似度则对纹理差异更敏感适合区分不同币种的花纹。def compare_similarity(roi, tmpl): # 方法1归一化交叉相关 result cv2.matchTemplate(roi, tmpl, cv2.TM_CCOEFF_NORMED) return result[0][0] def compare_histogram(roi, tmpl): # 方法2HSV 直方图相似度 def calc_hist(img): hsv cv2.cvtColor(img, cv2.COLOR_BGR2HSV) hist cv2.calcHist([hsv], [0, 1, 2], None, [8, 8, 8], [0, 180, 0, 256, 0, 256]) cv2.normalize(hist, hist) return hist hist1 calc_hist(roi) hist2 calc_hist(tmpl) return cv2.compareHist(hist1, hist2, cv2.HISTCMP_CORREL)TM_CCOEFF_NORMED 返回值的范围是 -1 到 1越接近 1 表示越相似。这里把它作为主要识别手段阈值设为 0.5低于这个值就认为是 unknown宁可漏报也不误报后续人工确认成本更低。HSV 直方图方法更适合做难分面值之间的交叉验证比如硬币图案接近、不过颜色有明显差异时。4.4 面积与颜色特征模板匹配的补充手段模板匹配有个痛点如果实际场景中硬币的图案与模板不一致——比如模板是正面的菊花图案待识别的是背面的国徽图案那匹配分数会直接崩掉。所以更稳妥的做法是结合硬币的几何特征来辅助判断。一元硬币和五角硬币除了图案不同直径也有明显差异。如果知道图像中硬币的像素半径就能用半径来辅助分类。方法是先校准一个比例拍一张已知半径的参考硬币计算“每毫米对应的像素数”然后用这个比例去换算待测硬币的实际直径。def classify_by_diameter(radius_px, px_per_mm): diameter_mm 2 * radius_px / px_per_mm if 24.5 diameter_mm 26.5: return one_yuan elif 19.5 diameter_mm 21.5: return five_jiao elif 18.5 diameter_mm 20.0: return one_jiao return unknown这种基于几何尺寸的分类方法在硬币不重叠、没有大角度倾斜的情况下准确率其实相当可观。把直径分类和模板匹配的结果结合起来投票比单独用任何一种方法都稳。实际做项目时我的做法是先用直径粗判断再用模板匹配做确认两个结果都指向同一面值才最终确定。5. 避坑记录硬币检测最常见的五个翻车现场5.1 同一枚硬币被检出多个圆现象HoughCircles 返回的结果里同一枚硬币的位置出现两三个圆心很近的圆导致硬币数量虚增。原因minDist 设置过小。HoughCircles 不限定圆心最小时会在局部区域里把同一个圆反复检出多次尤其是硬币边缘比较模糊时多个相近的圆心都能通过累加器投票。解决把 minDist 调整为略大于图像中硬币半径的值。比如图像中硬币半径约 60 像素minDist 就设 80 以上。还可以增加一个后处理对检出的圆按圆心距离做聚类距离小于阈值的圆合并成一个取半径均值。5.2 背景里的圆形物体被当成硬币现象桌面上有个圆形杯垫、一个圆形阴影程序把它当成硬币圈出来了。原因预处理只做了灰度、滤波、自适应阈值没有考虑颜色或边缘强度信息。杯垫虽然形状是圆但它和硬币在颜色、纹理上有明显差异几何特征无法区分。解决加一层颜色过滤。先把图像转到 HSV 空间设定硬币的颜色区间黄铜色、银色只保留在区间内的像素再去找圆。或者把检测圆的参门槛提高比如在找圆前做一个 HSV 掩码过滤。这一步对浅色木桌、白色纸张这类背景特别有效。5.3 检测结果里一枚硬币的轮廓断开现象硬币实际是完整的但 Canny 边缘图里它的轮廓断成了好几段findContours 提取不到闭合轮廓于是这枚硬币被漏检。原因硬币表面反光导致局部边缘亮度变化超出 Canny 双阈值的判断范围反光区域边缘梯度值低于低阈值被判为噪声滤掉了。解决Canny 之前先做高斯模糊ksize 从 5x5 提到 7x7 或 9x9让边缘更平滑Canny 之后用膨胀操作把断点补上。如果反光情况太严重可以在预处理阶段用形态学闭运算先膨胀后腐蚀把边缘沟壑填平。5.4 模板匹配把所有硬币都识别成同一种面值现象不论放进去的是几元、几角识别结果永远指向同一个模板。原因模板和 ROI 在亮度、对比度上差异过大。比如模板是明亮环境拍的实际检测图片偏暗归一化后的相似度被整体拉低阈值低于 0.5 时全部落回 unknown如果阈值设低了就全部往最高分那个模板上靠。解决不要只用单一相似度阈值。先用直方图方法对 ROI 做一次色彩分布统计排除颜色明显不匹配的模板再在剩下的候选中做基于模板的匹配。同时确保模板和实际检测图片的光照条件尽量一致采集模板时多拍几种光照版本匹配时取最高分。5.5 代码能跑但结果占比奇怪ROI 切图没做掩码现象识别结果里背景区域呈现出类似硬币纹理的特征导致误判数量增加。原因ROI 切片后未生成圆形掩码就把矩形区域送入识别流程矩形中的背景像素桌面、纸张参与了特征计算。背景的纹理在某些面值模板上碰巧相似拉高了相似度分数。解决对每个 ROI 都生成与硬币半径一致的圆形掩码用 bitwise_and 把掩码外像素置黑。这个操作别省它直接决定识别特征“看”的是硬币还是背景。如果 ROI 中的硬币颜色与背景相近掩码边缘还可能残留背景像素可以再加一层形态学腐蚀把边缘收紧。6. 二次开发指南从硬币检测扩展到圆形工件识别6.1 改造识别逻辑从模板匹配到特征向量分类做这个硬币项目最常遇到的情况是需求直接变成“帮我也认一下这个圆形零件”零件表面可能没有固定图案或者图案因为加工批次不同而差异很大这时模板匹配就不灵了。常见做法是把识别逻辑从模板匹配换成特征向量 分类器。提取特征的思路是这样从 ROI 里计算一组数值描述比如硬币的 Hu 矩、颜色直方图、纹理特征局部二值模式、直径值。然后把每个 ROI 的这些数值拼成一个特征向量交给 SVM 或 KNN 分类器去判断类别。OpenCV 自带 cv2.ml.KNearest_create 和 cv2.ml.SVM_create不需要引入新的依赖。改造时保留现有的找圆和 ROI 提取模块只替换 recognize_coin 内部的实现对项目其他部分完全无侵入。6.2 接摄像头做实时检测循环读帧与性能优化如果把静态图片检测改成摄像头实时检测代码层面的改动不大主要是把读图那行换成 VideoCapture 的循环读帧但性能问题会立刻暴露出来。处理一帧 800x600 的图像完整走一遍预处理加霍夫圆变换普通 CPU 上大约需要 30 到 80 毫秒勉强够 15 帧左右如果图像分辨率到 1920x1080一帧可能逼近 200 毫秒就卡得没法用了。我一般会做两件事一是把输入帧降采样比如每隔一帧处理一次而且把帧缩放到宽度 640 左右检测时间能锐减到原来的三分之一二是把 ROI 识别的分辨率调低模板从 128x128 降到 96x96识别阶段的时间也能节省将近一半。注意降采样的尺度变化会影响实测直径的换算需要同步修正 px_per_mm 参数。6.3 验证方法自己动手做回归测试改完代码之后验证环节不能只靠肉眼把结果放大看。我的习惯是准备三组测试图片一组是理想状态下的摆拍图硬币分散、背景干净一组是模拟真实场景的图硬币有重叠、背景有桌面纹理、光照不均匀一组是负样本比如纯背景、非圆形物体。每次改完代码后把三组图全部跑一遍记录检出率、误检率和面值识别准确率盯住这三个数值有没有回退。6.4 调参技巧把关键参数抽出来做成配置文件这个项目调参涉及的量不算少高斯模糊的 ksize、Canny 双阈值、HoughCircles 的五个参数、面积占比阈值、模板匹配阈值。一开始我是把它们硬编码在代码里的每次要调就得改代码、重跑脚本效率很低。后来学乖了把这些参数全部挪到一个 config.py 或者 YAML 文件里# config.py DETECT_PARAMS { gaussian_ksize: 5, canny_low: 50, canny_high: 150, hough_dp: 1.2, hough_min_dist: 80, hough_param1: 100, hough_param2: 60, hough_min_radius: 40, hough_max_radius: 200, area_ratio: 0.75, match_threshold: 0.5, template_size: (128, 128), }这样拿到任何一张新场景下的图只需要改配置文件不用动一行逻辑代码。而且参数一旦定下来后续尝试迁移到其他圆形工件时唯一要重新标定的就是 min_radius、max_radius 和 px_per_mm 这三个和应用场景强相关的量其余参数基本可以沿用硬币场景的初始值再微调。从那以后我每次拿到一个视觉检测需求都会先强迫自己把“参数可配置”这个习惯做在前面哪怕只有三个参数也单独建文件。项目跑通之后回过头看最坑的往往不是算法选型而是那些散落在代码里的魔法数字——可千万别让后来接手的人猜某个阈值是哪个场景定的。这套硬币检测的流程和结构希望能帮到你。本文还有配套的精品资源点击获取