简介这是一份面向OpenCV初学者与图像处理学习者的硬币检测识别示例资源围绕“从照片中识别硬币个数并判断总价值”的实用场景展开可帮助读者掌握图像预处理、轮廓提取、特征匹配等经典流程也适合作为数字图像处理课程的动手练习。压缩包共13个文件核心为1个Python脚本另有12张JPG图像分别承担模板图像、测试图像与结果图像的角色包体仅7.39MB便于快速下载与本地运行。目前已有2250人学习下载验证了其在入门教学与课程设计中的参考价值。读者通过该脚本可了解硬币区域分割、边缘检测、直径或纹理特征提取以及基于模板匹配或几何关系的价值判定思路同时可对照输出的结果图片直观比较不同阈值或参数对识别效果的影响并借助简洁的代码结构快速修改与复现适合作为毕业设计、实验报告或竞赛练习的起点。1. 硬币检测识别OpenCV 入门第一个实战项目如果你在网上搜 python 硬币检测 opencv大概率会看到一堆演示图但真正能落地复现的源码包很少。我这次拿到的是一个模拟项目X的源码包内核很简单读图、灰度化、边缘检测、找圆、特征提取、匹配分类一条线走完。这里先说结论用 OpenCV 做硬币检测比用深度学习模型更适合入门一是样本需求低几枚硬币就能玩二是参数可解释性强每个步骤都看得见摸得着三是速度极快普通笔记本跑一帧 30 毫秒以内。它解决的核心问题是「怎么在复杂背景下把圆形物体稳定框出来并把不同面额分开」。适合的人群是有 Python 基础、想从图像处理往视觉工程走的从业者也适合刚开完计算机视觉课程想找实战练手的学生。下面我从预处理一路拆到识别和避坑把参数、边界和翻车点全部摊开。2. 硬币图像预处理从灰度化到边缘检测的参数选型2.1 预处理流程三件套与参数选择拿到硬币图像的资源后第一步不是急着调霍夫圆而是把图像预处理做扎实。我一般会按这个顺序走灰度化 → 高斯模糊 → 边缘检测。灰度化是为了把三通道压缩成单通道减少计算量高斯模糊是为了抑噪因为硬币表面的纹理、反光、污渍会在边缘检测阶段变成一堆假轮廓。常见做法是直接用 OpenCV 的cv2.cvtColor转灰度然后cv2.GaussianBlur做平滑。高斯模糊的核大小这个参数很关键核太小比如 3×3抑不了噪核太大比如 15×15会把硬币边缘也磨平导致后续霍夫圆检测时不闭合。我当时用 5×5 的核调通了一版换到 7×7 时边缘明显变模糊漏检率上升。如果检测目标是 1 元、5 角、1 角这种小硬币5×5 基本够用如果画面里硬币占像素面积小比如照片里硬币直径只有 30 像素建议降到 3×3优先保边缘。import cv2 import numpy as np def preprocess(image_path, blur_kernel5): # 读图BGR 格式 img_bgr cv2.imread(image_path) if img_bgr is None: raise ValueError(图像读取失败检查路径) # 转灰度后面所有算法都基于单通道减少计算量 gray cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY) # 高斯模糊核大小必须是正奇数5 是经验值 blurred cv2.GaussianBlur(gray, (blur_kernel, blur_kernel), 0) return img_bgr, gray, blurred # 返回三个结果原图、灰度图、模糊图 # 后续步骤都从 blurred 开始做原图只用于最后可视化绘制这里有个容易被忽略的点GaussianBlur的第三个参数sigmaX我直接写成 0意思是让 OpenCV 根据核大小自动算标准差。有些新手会纠结这个值实测下来自动计算的效果已经很稳定手动指定反而容易过曝或过糊。2.2 边缘检测之后为什么直接用霍夫变换会漏检预处理完接着就是边缘检测。常见做法是用 Canny 算子它比 Sobel 这类一阶微分算子好在能输出细的单像素边缘而且有双阈值抑制假边缘。Canny 的两个阈值参数直接决定后续找圆的质量。我在做某跨平台系统的硬币识别模块时一开始直接把 Canny 的阈值设成 100 和 200结果硬币和背景对比度差的图片里硬币边缘断成一截一截霍夫圆检测根本找不到完整的圆。后来改成先用大津法cv2.threshold配合THRESH_BINARY THRESH_OTSU算出一个自适应阈值再把 Canny 的低阈值设为它的一半高阈值设为它本身效果立刻稳定了。def canny_with_otsu(blurred): # 用 Otsu 自动算阈值避免人工拍脑袋 # 这里 threshold 返回的第一个值是计算出来的阈值 otsu_th, _ cv2.threshold(blurred, 0, 255, cv2.THRESH_BINARY cv2.THRESH_OTSU) low_th int(otsu_th * 0.5) high_th int(otsu_th) edges cv2.Canny(blurred, low_th, high_th) return edges, low_th, high_th # 逻辑说明Otsu 会基于灰度直方图自动找一个区分前景和背景的阈值 # 低阈值设一半是为了让边缘检测对弱边缘更敏感 # 高阈值直接采用 Otsu 值避免强噪声被算成边缘这个改动非常关键。如果你拿着别人给的源码包跑出来漏检一堆先查预处理这一步是不是写死了阈值。图像的光照环境一变固定阈值必翻车。顺带提醒Canny 输出的边缘图不要直接做形态学闭运算除非边缘断裂非常严重否则宁可通过调低阈值解决也不要动膨胀算子——膨胀会把互相靠近的硬币边缘黏在一起后面圆检测就没法看了。3. 霍夫圆检测HoughCircles 的参数调优实践3.1 霍夫圆检测原理简述与 OpenCV 接口预处理做完到了整个流程的核心——找圆。OpenCV 里的霍夫圆检测封装在cv2.HoughCircles它的原理可以粗浅地理解为边缘图上每一个前景像素点都投票给所有可能经过它的圆心票数最高的圆心位置被认定为真圆。这个原理决定了它有四个绕不开的参数dp、minDist、param1、param2。先说接口形式再逐个说参数怎么设。OpenCV 接口长这样def detect_circles(edges, hough_paramNone): # 默认参数组基于 500x500 左右的分辨率 if hough_param is None: hough_param { dp: 1, minDist: 50, param1: 100, param2: 35, minRadius: 5, maxRadius: 100 } # HoughCircles 内部会对输入图再跑一次边缘检测 # 但我们传入 edges 后还是会用到 param1 这个阈值所以保持一致 circles cv2.HoughCircles( edges, cv2.HOUGH_GRADIENT, dphough_param[dp], minDisthough_param[minDist], param1hough_param[param1], param2hough_param[param2], minRadiushough_param[minRadius], maxRadiushough_param[maxRadius] ) if circles is not None: return np.uint16(np.around(circles[0])) return []参数说明这里必须讲透。dp是累加器分辨率与图像分辨率的比值设 1 表示两者一致设为 2 则累加器分辨率减半速度更快但精度下降。minDist是两个圆心之间的最小距离这个值设得比硬币直径小就会把同一个硬币检出多个圆设得太大则会漏掉靠近的硬币。param1是 Canny 高阈值因为HoughCircles内部会再走一遍 Canny最好和前面预处理里的 Canny 高阈值保持一致。param2是累加器阈值越小越容易检出假圆越大越容易漏检这是最需要反复试的参数。3.2 参数实验minDist、param2、minRadius 的配合我用源码包里的示例图白色背景下桌面上摆了几枚硬币跑的时候param230左右能检出全部硬币但会多出两三个假圆调到 45 之后假圆消失但有一枚边缘模糊的 1 角硬币漏检。这是一个典型的精度和召回的权衡。常见做法是固定dp1、param1100然后单独调param2每调一次跑一遍全量测试集画出来看结果。minRadius和maxRadius也非常重要。如果你知道画面里硬币直径大概在 40~80 像素之间就把minRadius30、maxRadius90设上这样能砍掉一堆跟硬币无关的圆形物体比如杯底、瓶盖系统稳健性提升一个档次。但注意这些上下限不要卡得太死——因为硬币在画面里大小和拍摄距离直接相关你拍的是一枚 1 元硬币同 1 角硬币直径差不少区间太窄会把小硬币滤掉。def tune_min_dist(blurred, edges, candidate_dist(30, 50, 70)): for dist in candidate_dist: circles cv2.HoughCircles( edges, cv2.HOUGH_GRADIENT, dp1, minDistdist, param1100, param235, minRadius20, maxRadius100 ) if circles is None: print(fminDist{dist}: 没有检测到圆) else: c_num len(circles[0]) print(fminDist{dist}: 检测到 {c_num} 个圆) # 这里可以把圆画出来目测是否有重复圆/漏检参数之间是联动关系。举例说minDist30时因为距离允许更近同一枚硬币的边缘投票会产生多个相邻的圆心候选这时候param2如果偏低比如 25就会输出同一枚硬币的多个圆但如果是硬币互相挨着放minDist设 70 又会把两个紧挨的硬币当作同一个圆合并掉。我当时把硬币一张张分开摆minDist设为硬币直径的 1.2 倍左右效果最好。重叠场景下面会单独讲怎么处理。4. 硬币识别从圆到轮廓的特征提取与分类4.1 从圆到 ROI分割硬币区域并计算关键特征找圆完成之后识别环节接棒。识别不是直接拿一整张图去做而是先把每个圆的 ROI 抠出来然后在 ROI 上提取特征最后交给分类器。先看怎么把圆变成 ROI。霍夫圆返回的是 (x, y, r) 三元组其中 x、y 是圆心坐标r 是半径。常见做法是用这些参数从原图里裁出正方形区域注意做边界保护避免圆心靠近图像边缘时数组越界。def extract_rois(img_bgr, circles, pad_ratio0.1): rois [] for x, y, r in circles: # 半径 10% 的 padding防止裁掉硬币边缘 pad int(r * pad_ratio) x_min max(0, x - r - pad) x_max min(img_bgr.shape[1], x r pad) y_min max(0, y - r - pad) y_max min(img_bgr.shape[0], y r pad) roi img_bgr[y_min:y_max, x_min:x_max] if roi.size 0: rois.append((roi, (int(x), int(y), int(r)))) return rois边界保护为什么重要拿一张硬币放在照片边缘的图试一下就知道了。如果没有max、min的保护裁出来的 ROI 尺寸会小于预期后面提取特征时数组维度对不上或者直接报错。pad_ratio参数我通常设 0.1~0.2太大了会把相邻硬币带进 ROI干扰特征提取。4.2 识别策略模板匹配还是决策树分类从 ROI 里提取什么特征这决定了识别效果。模拟项目X的源码包里用了三个基础特征面积、圆度、灰度均值。我的建议是你不要止步于此至少把颜色特征也加进去——硬币面额不同颜色发红、发黄、发白差异非常大而灰度特征很容易受光照影响。圆度计算有个非常经典的公式圆度 4 * pi * 面积 / (周长^2)。一个正圆的圆度是 1硬币因为锯齿边缘通常落在 0.9 左右如果是长方形物体圆度会掉到 0.6 以下。用它来过滤误检比只用面积稳定得多。def extract_features(roi, gray): # 对 ROI 计算轮廓特征 # 先做二值化再找轮廓 th, binary cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY cv2.THRESH_OTSU) contours, _ cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if not contours: return None # 取最大轮廓避免 ROI 边界的杂散干扰 c max(contours, keycv2.contourArea) area cv2.contourArea(c) perimeter cv2.arcLength(c, True) if perimeter 0: return None circularity 4 * np.pi * area / (perimeter * perimeter) # 灰度均值用掩膜只统计硬币内部避免把背景算进去 mask np.zeros(gray.shape, dtypenp.uint8) cv2.drawContours(mask, [c], -1, 255, thickness-1) mean_val cv2.mean(gray, maskmask)[0] # BGR 通道均值用于区分不同年份/版本硬币的颜色差异 bgr_mean [cv2.mean(img_bgr[:, :, i], maskmask)[0] for i in range(3)] return { area: area, circularity: circularity, gray_mean: mean_val, bgr_mean: bgr_mean }特征拿到之后分类可以用两条路。一条是模板匹配预先采集每个面额硬币的标准图计算特征或直方图新来的 ROI 用cv2.matchTemplate或特征向量距离欧氏距离去匹配另一条是训练一个决策树分类器输入特征向量输出面额标签。对于只有三四类面额的小项目我强烈建议先用模板匹配因为不用标注大量样本而且可解释性强——匹配不上时你能直接看到跟哪张模板差异最大。决策树更适合你在资源包里顺手提供了标注数据的情况比如每类 30 张以上这时用小决策树深度 5 以内就能拿到 90% 以上的准确率。5. 避坑排查硬币检测常见的翻车现场与修复方法5.1 有光照反光边缘图里硬币内部出现一圈伪轮廓霍夫圆检出一堆假圆现象检测结果显示每个硬币旁边多出几个半径更小的圆细看这些圆对应的是硬币表面反光形成的亮斑边缘。原因硬币表面有金属光泽在室内灯光下反光区域灰度值接近白色Canny 在处理时会把高光边缘也当成强边缘。而HoughCircles找圆不区分边缘来源只要投票够多就输出圆。解决在预处理阶段对高光做抑制。我习惯在灰度化之后加一步顶帽变换cv2.morphologyEx配合MORPH_TOPHAT能把小于结构元素尺寸的高光斑纹分离出来然后用原灰度图减去顶帽结果。实测这一步能让反光区域的边缘响应大幅下降假圆数量直接砍半。还有一个更简单的操作把param2从 30 调高到 40 及以上假圆也会减少但代价是真正的弱边缘硬币可能漏检所以两种手段结合使用。5.2 硬币互相重叠或紧贴minDist参数怎么调都别扭现象把硬币叠放时有的硬币被当成两个圆输出有的相邻两个硬币被合并成一个半径为两者之和的大圆。原因minDist是圆心间最小距离硬币重叠后两个圆心距离小于硬币直径参数设小了不行同时霍夫累加器里重叠区域的票数叠加可能导致圆心位置偏移合并成一个大圆。解决我的处理顺序是——第一步先设置minDist为硬币最大直径的 0.8 倍比单币直径略小让每枚硬币至少保留一个候选圆心。第二步对输出结果做非极大值抑制NMS把同一个硬币的多个候选圆按置信度可以用param2附近的累加器强度近似保留最高分的一个删除重叠的。第三步如果不要求精确分割只要求计数和识别就接受重叠部分被截断的 ROI识别特征里只用中心区域的有效像素。def nms_circles(circles, min_dist_thresh): # circles: np.array 每行为 [x, y, r, score] circles sorted(circles, keylambda c: -c[3]) # 按置信度从高到低排 keep [] while circles: best circles.pop(0) keep.append(best) circles [c for c in circles if np.sqrt((c[0] - best[0])**2 (c[1] - best[1])**2) min_dist_thresh] return keep这段代码的逻辑不复杂先按分数排序每次取最高的圆然后把它附近距离内的圆全扔掉这样留下来的每个圆在空间上都互相隔开。参数min_dist_thresh设成多少合适硬币直径的一半左右就够如果设得太大紧贴的两个不同硬币中分数较低的那个会被误删。5.3 面额识别总是把 5 角和 1 元搞混现象模板匹配分类后5 角硬币经常被识别成 1 元或者反过来。ROI 可视化后看得很清楚两者大小接近。原因5 角硬币的直径和 1 元比较接近面积差只有 20% 左右。如果只用面积或者只用灰度均值做特征两者分布区间高度重叠。而模板匹配如果用的是灰度图直接比对表面花纹差异也会被光照噪声盖过去。解决把特征维度加宽。我加上 HSV 空间里的色调均值后两者区分立刻显著了——5 角偏黄红1 元偏银白。另外给模板匹配换成特定掩膜匹配cv2.matchTemplate支持mask参数可以把中心图案区域和外围文字区域拆开分别匹配实际使用中识别率能从 75% 提升到 92% 左右。5.4 图像尺寸不统一同一枚硬币在两张图中差 3 倍大现象运行源码包自带的 sample 图都正常换成自己手机拍的图后一枚硬币占满半个屏幕导致minRadius和maxRadius完全失效。原因这是非常低级但常见的坑。手机拍摄距离不固定硬币在画面中的像素直径可能在 100~600 之间波动。写死的minRadius20, maxRadius100只能覆盖其中一小段。解决在做霍夫圆检测之前先做一次尺寸归一化把图像长边缩放到 500 像素。缩放之后硬币直径会落在一个相对稳定的范围比如 60~160 像素然后在这个范围内搜圆。这样既控制了minRadius/maxRadius的区间又统一了后续特征提取的尺度面积特征不需要再做缩放归一化。def resize_long_edge(img_bgr, target_long500): h, w img_bgr.shape[:2] scale target_long / max(h, w) new_size (int(w * scale), int(h * scale)) resized cv2.resize(img_bgr, new_size, interpolationcv2.INTER_AREA) return resized, scale注意这里用的是INTER_AREA插值缩放比例大于 1 时用它容易产生锯齿但我们的场景是缩小用INTER_AREA能保留更多边缘细节。缩小到多长合适500 是经验和速度的折中太长会拖动辄几百毫秒太短的话小硬币边缘会丢失。5.5 硬币边缘有锯齿找圆结果和真实边缘差 2~3 像素现象视觉上硬币轮廓是整齐的圆但HoughCircles返回的半径肉眼可见地偏小或偏大导致 ROI 裁切后旁边总露一条背景边。原因Canny 边缘检测出的像素级轮廓在斜向位置会产生栅格化误差加上高斯模糊会轻微收缩边缘位置所以半径偏差几个像素是常态。解决常见做法是在霍夫圆输出之后做一步亚像素细化。我一般用cv2.findContours在 ROI 内重新找一次轮廓然后对轮廓点做圆拟合并求半径最小二乘拟合把霍夫圆的半径当作初值拟合结果作为修正值。这一小步能把半径误差从 3 像素压到 1 像素以内识别时的 ROI 切割质量明显更好。6. 进阶用法用模板匹配做面额识别两步把准确率拉上去如果你跑通了前面的检测和分类会发现核心难点已经不在「找圆」上而在「识别准确率」。这里分享一个我在模拟项目X里反复迭代出来的模板匹配方案——分两步走准确率能从 80% 左右拉到 95% 以上。第一步是建模板库存。对每一类硬币采集 5~10 张标准图不同的旋转角度、光照强度提取的模板不需要用原始像素图而是用结合灰度与边缘方向的组合特征。具体做法是将 ROI 归一化到固定尺寸比如 128×128然后对每个模板提取 Sobel 梯度幅值图把这幅梯度图当作匹配用的「模板图」。为什么用梯度图而不是原图因为硬币表面的图案在梯度域上对光照变化更鲁棒阴影和反光对梯度的影响远小于对灰度直方图的影响。第二步是两阶段匹配。第一阶段用颜色特征粗筛计算 ROI 的 BGR 均值跟每个模板库里的 BGR 均值做距离比较保留最接近的两到三个候选面额。第二阶段在这几个候选里做精匹配——把 ROI 和候选模板的梯度图用cv2.matchTemplate计算归一化相关系数取分数最高且大于阈值的那个作为最终结果。如果所有的候选分数都低于阈值就判定为「未知而不是硬分一个面额出来。这个拒绝机制很关键因为它能避免把背景误判成硬币。def classify_with_gradient(gray_roi, template_db, color_feature): # 1. 颜色粗筛取 top3 候选 candidates sorted( template_db, keylambda t: np.linalg.norm(np.array(t[bgr_mean]) - np.array(color_feature)) )[:3] # 2. 梯度精匹配 roi_gray cv2.resize(gray_roi, (128, 128)) roi_grad cv2.Sobel(roi_gray, cv2.CV_32F, 1, 0, ksize3) roi_grad cv2.convertScaleAbs(roi_grad) best_score -1 best_label unknown for tpl in candidates: # tpl[grad] 是训练时存好的梯度模板 res cv2.matchTemplate(roi_grad, tpl[grad], cv2.TM_CCOEFF_NORMED) score res[0, 0] if score best_score: best_score score best_label tpl[label] return best_label, best_score这套方案跑下来最怕的不是表面划痕而是极端光照带来的阴影。从那以后我每次做硬币识别的实验都强制自己先走一遍光照归一化——把 ROI 直方图均衡化后再进梯度计算否则光线一变之前的模板匹配结果就会翻车。说句实在话OpenCV 硬币检测项目的门槛不高但把阈值调到不挑图、把识别做到不挑光线才是真正的分水岭。希望这些踩坑经验和参数调优方法能帮到你少走弯路。本文还有配套的精品资源点击获取