霍夫变换是图像处理里最经典也最容易被误读的概念之一。我在刚开始接触它的时候,心里其实挺抵触的:直线检测为什么不能直接拟合,非要绕到另外一个空间里去投票?圆检测就更头大了,三个参数放在一起,OpenCV里那几个参数名看着就晕,只能一次一次试,试到人麻。如果你也有同样的感受,这篇东西应该能帮到你。我会先把霍夫变换最核心的投票思路讲清楚,再用圆检测这个实际场景,带你完整地走一遍从原理到调参的全过程,中间会穿插一些我在项目里踩过的坑和沉淀下来的经验。
1. 先建立直觉:霍夫变换到底在做什么
1.1 从图像空间换到参数空间:为什么绕这一大圈
先说直线检测。你有一张图,边缘检测之后得到一堆白点,你想知道哪些点共线。最直觉的做法是拿最小二乘去拟合,但一旦画面里有多条直线、或者有大量噪声点,拟合结果就很容易被带偏。霍夫变换换了一个思路:我不直接问“哪些点连成直线”,而是问“每一条可能的直线,有多少个点支持它”。
这里的核心操作是把图像空间里的点映射到参数空间。假设直线用极坐标表示:
ρ = x cosθ + y sinθ
其中 ρ 是原点到直线的距离,θ 是直线的法线与 x 轴的夹角。图像里的一个点 (x, y),在 (θ, ρ) 参数空间里会变成一条正弦曲线。原因很简单:这个点可以属于无数条直线,对应参数空间里无数个 (θ, ρ) 组合,这些组合在地图上连起来就是一条曲线。
现在轮到第二个点。它也会贡献一条正弦曲线。两条曲线如果有交点,这个交点对应的 (θ, ρ) 就代表一条同时通过这两个点的直线。点越多,就有越多曲线汇聚到同一个地方,这个地方就会变成一个明显的“热点”。绕这一大圈,本质上就是把几何问题转换成统计问题,好处是它天生能容忍噪声和断线,哪怕一条直线只露出了一部分,只要足够多的边缘点参与投票,它依然能被找出来。
我举个例子。你参加一个聚会,每个人都在黑板上画一条线,画的是自己认为是“屋里所有人共线的方向”的答案。如果这条线真的存在,那么大家的答案会指向非常接近的方向,黑板上会出现一个明显的聚集区域。如果根本没这条线,答案就散成一团。霍夫变换做的就是这件事,只不过它是把候选答案画在一个离散的计数网格里。
1.2 累加器投票机制:直线是怎么被“数”出来的
实际实现里,我们不会真的画无限细的曲线,而是把 (θ, ρ) 空间切成一个个小格子,每个格子就是一个计数器。遍历每一个边缘点,为每个点在所有可能的 θ 下计算对应的 ρ,然后给对应格子的计数器加一。扫完所有点之后,数值最高的格子就代表“支持者最多”的直线。
累加器的大小取决于离散化的粒度。θ 一般按 1 度步长去扫,从 0 到 180 度;ρ 的范围跟图像对角线长度有关,步长通常取 1 个像素。格子太粗会损失精度,太细又会把本该属于同一条线的投票分散掉,导致峰值不明显。这是一个需要权衡的点。
为什么只拿边缘点来投票,而不是用全图所有像素?因为非边缘区域的信息量很低,参与投票只会增加计算量、稀释峰值。边缘检测相当于先做了一轮“候选人筛选”,霍夫变换再从中统计最终答案。这也是为什么几乎所有霍夫直线检测流程里,前面都会接一个边缘检测或者梯度计算。
OpenCV 里提供了两种直线霍夫:经典版 HoughLines 和概率版 HoughLinesP。HoughLinesP 不是对每个边缘点都投票,而是随机抽样一部分点,找到直线后再沿直线的方向去连接边缘点,检查这段区域内的连续性。这种方式在图像比较大、直线比较多的时候会快很多,检测出来的也是带端点的线段而不是无穷直线。如果你是做车道线、文档线条这类检测,优先用 HoughLinesP。
2. 圆检测为什么不能简单套公式
2.1 三维参数空间的计算压力:标准霍夫圆检测有多贵
圆和直线不一样。直线只有两个参数,圆有三个:圆心横坐标 a、圆心纵坐标 b、半径 r。圆的方程写出来是:
(x - a)² + (y - b)² = r²
图像空间的一个点,对应参数空间里的一个三维曲面。按同样的投票逻辑,我们必须遍历所有可能的圆心和半径,给三维数组累加。这种方式在理论上是严谨的,但计算量很快就会爆炸。
假设图像是 500×500,圆心 a 和 b 各有 500 种取值,半径范围 0 到 250,那一共就是 500×500×250,约 6250 万个累加器格子。每个边缘点还要在这么大的空间里画曲面,你算一下就知道,普通电脑根本跑不动。所以实际工程里几乎不会直接实现标准三维霍夫圆检测,而是用近似方法。
2.2 霍夫梯度法:先找圆心再定半径的聪明近似
OpenCV 的 HoughCircles 用的是霍夫梯度法,它把三维问题拆成了两步。
第一步,找圆心。圆的边缘点上,梯度方向有一个特点:它大体指向圆心或者背离圆心。于是我们可以沿着每个边缘点的梯度方向“走线”,把这些线经过的像素位置累加到一个二维数组里。真实圆心会被大量边缘点的梯度线穿过,数值会显著高于其他位置。这样我们就避开了半径维度,只需要一个二维累加器就能锁定圆心候选。
第二步,对每个候选圆心,统计它到周围所有边缘点的距离。如果某个距离值出现了足够多次,说明这个半径对应的圆周上有大量边缘点,那么我们就找到了一个圆。这一步本质上是做半径直方图,峰值即半径。
这个近似思路在实际中跑得非常快,但它也有自己的弱点。梯度方向很容易受到噪声和局部光照变化的影响。比如圆内部有纹理,或者背景里正好有一条强边缘经过,累加器里就会出现假的圆心峰。后面调参时遇到的很多奇葩问题,根源都在这里。
2.3 OpenCV各参数的真实作用与联动关系
用 OpenCV 写圆检测时,你面对的是这一行参数:
cv2.HoughCircles(img, cv2.HOUGH_GRADIENT, dp, minDist, param1, param2, minRadius, maxRadius)
我把每个参数用大白话解释一遍:
- img:输入图像。官方建议是灰度图,实际上你最好再先做一次高斯模糊,原因后面讲。
- dp:累加器分辨率与图像分辨率的比值。dp=1 表示累加器和原图同样大,dp=1.2 表示累加器是原图的 1/1.2 倍,也就是更小一点。dp 越大,计算越快,但圆心坐标精度越低。
- minDist:两个圆心之间的最小距离。比这个距离更近的候选圆会被当作同一个圆处理,可以理解为非极大值抑制的距离阈值。
- param1:传给内部 Canny 边缘检测的高阈值。低阈值通常自动取它的一半。它控制哪些像素可能作为边缘点参与投票。
- param2:圆心候选的累加器阈值。这个值越小,越容易检出圆,但假圆也越多;越大,要求越严格,漏检概率越高。
- minRadius、maxRadius:检测圆的半径范围,单位是像素。
这些参数没有一个是可以独立调的。比如你把 minRadius 设得很小,再把 param2 设小,图像上任何一个小弧线都有可能被当成圆;反过来如果你把 minDist 调大,两个实际靠得很近的圆就会被吞掉一个。调参的关键不是记参数名,而是理解每个参数在“抑制假阳性”和“保留真阳性”之间扮演什么角色。
3. 手把手跑通一个圆检测Demo
3.1 筛选实验图:不是所有带圆的图都适合直接检测
如果你刚接触圆检测,我强烈建议先别拿真实照片试。真实照片里圆的边缘往往模糊,背景又复杂,第一步就会劝退你。更合理的做法是准备一张合成图,用画图工具或者代码生成几个实心圆、空心圆、带轻微噪声的圆,然后在上面调参数。
当你大概摸清楚参数的作用之后,再换成硬币照片、药片照片、或者类似需要工业检测的场景。这里有一个判断标准:圆的边缘必须清晰,圆和背景之间最好有亮度差,圆本身不要有太多纹理。如果一张图里圆圈都快看不清了,那调霍夫参数的意义不大,不如先回去做预处理。
3.2 可复用的OpenCV检测代码
下面是一段可以直接跑起来的 Python 代码。我加了一些注释,重点不是“能跑”,而是让你看清楚每一步在干嘛。
import cv2 import numpy as np def detect_circles(image_path, radius_range=(20, 120)): # 1. 读图 img = cv2.imread(image_path) if img is None: print("图像读取失败,先检查路径") return # 2. 转灰度 + 高斯模糊 gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) gray = cv2.GaussianBlur(gray, (9, 9), 2) # 3. 霍夫圆检测 circles = cv2.HoughCircles( gray, cv2.HOUGH_GRADIENT, dp=1.2, minDist=50, param1=180, param2=35, minRadius=radius_range[0], maxRadius=radius_range[1] ) # 4. 画结果 if circles is not None: circles = np.uint16(np.around(circles)) for x, y, r in circles[0]: cv2.circle(img, (x, y), r, (0, 255, 0), 2) cv2.circle(img, (x, y), 2, (0, 0, 255), 3) cv2.imshow("detected circles", img) cv2.waitKey(0) cv2.destroyAllWindows() detect_circles("circles.png", radius_range=(20, 120))有几个顺序问题值得强调。灰度转换没什么好说的,关键是高斯模糊。高斯模糊半径(这里是 9×9 的核)要和圆的尺寸匹配,核太小起不到抑制噪声的作用,核太大又会把圆边缘磨得太平,导致梯度方向信息失真。经验上,当圆的半径在 20 到 100 像素之间时,9×9 的核是一个比较安全的起点;如果圆的半径很小,可以考虑 5×5。
另外注意,HoughCircles 内部其实会做一次 Canny 边缘检测,所以它在设计上希望输入的是“未做过边缘检测的灰度图”。有些人习惯先 Canny 再传入,反而会丢失梯度信息,导致检测效果变差。这是一个比较常见的误区。
3.3 一套实用的参数搜索顺序
调参最忌讳的是什么?一上来就动 param2,把 30 改成 60,不行再改成 20,越改越离谱。我建议你按下面这个顺序来:
第一步,估算半径范围。先在图像编辑器里量一下目标的直径,或者用代码打印几个点的坐标算一算。给 minRadius 和 maxRadius 一个尽可能紧的区间,比让算法在 0 到 500 的范围里瞎搜要高效得多。
第二步,确定 minDist。如果你知道图中两个圆之间的最小间距,比如 50 像素,那就把 minDist 设为 50 左右。如果不知道,先设一个大一点的数值,比如图像对角线的十分之一,然后逐步缩小,直到恢复出相邻圆。
第三步,固定 param1,调整 param2。param1 常用范围是 150 到 250,可以先固定成 180。param2 的典型值在 20 到 60 之间,我习惯从 40 起步:如果发现漏检,就往下调;如果发现假圆太多,就往上调。注意 step 不要太大,一次改 10 以内。
第四步,如果以上怎么调都效果不好,再回头检查高斯核尺寸、光照一致性,而不是继续钻在霍夫参数里死磕。很多漏检问题是前处理造成的,后面我会详细展开。
4. 常见翻车现场:圆检测问题排查与调参心得
4.1 漏检的根源:预处理比阈值更值得怀疑
一个圆明明就在那里,但 HoughCircles 就是检不出来,这是最容易让人心态爆炸的场景。我遇到过的漏检原因,按概率排序大概是:边缘对比度不够、圆的边缘断断续续、半径范围没给对、param2 设得过高。
对比度不够最常见。比如浅色药片放在浅色传送带上,圆心周围的边缘亮度差很小,梯度幅值低,内部 Canny 直接把它过滤掉了。解决思路不是继续降 param2,而是先用对比度增强、直方图均衡化,或者用形态学顶帽操作把背景去掉。之前做过一个药片分拣的视觉方案,图像整体发灰,直接把灰度图送进去,圆检测率不到一半;做了顶帽变换之后,检测率一下到了九成以上。
边缘不连续的问题,可以用闭运算把断裂的边缘连起来。闭运算是先膨胀后腐蚀,能把细小缝隙填上,但要注意核的大小,太大了会把相邻圆黏在一起。另一个思路是调低 param1,因为它是 Canny 的高阈值,阈值越低保留的边缘越多,边缘就不容易断。
4.2 假圆与重圆:minDist和param2如何配合
假圆多,通常是累加器里出现了不少虚假峰值。真正圆的圆心累积值一定是最高的,但某些纹理或者噪声也能累积到足够的票数。这时候先不要急着把 param2 一刀切调到很高,因为调高了假圆确实少了,真圆也可能一起消失。
正确做法是先确认 param2 临界值:从大到小调,记下真圆开始出现的那个点,再往回调一点。比如 45 的时候一个圆都检测不到,40 出现第一个真圆,38 时所有真圆都稳定出现,那就定在 38 附近,不要贪心调到 30。
重圆则多半是 minDist 太小。minDist 小于真实圆心间距时,同一个圆会在相距很近的位置被同时检出。解决办法很简单:把 minDist 设为略大于最大圆心间距。如果你不确定,可以先用一个较大的值,比如图像短边的十分之一,再观察是否有圆丢失。
4.3 圆心偏移:梯度方向被噪声带偏怎么办
圆心位置偏移是一个很难排查的问题,因为视觉上那个圆真的画在目标上,只是偏了几个像素。对定位精度要求高的场景,这几个像素就可能是致命的。
偏移根源大多数在梯度方向。霍夫梯度法假设边缘点梯度严格指向圆心,但实际图像里,圆心内部如果有文字、纹理,或者光照不均匀,梯度方向就会偏向这些干扰因素。另外,圆边缘太细也可能导致梯度方向不稳定,因为单个像素的幅值容易被噪声改变方向。
我常用的解法是加宽边缘。先用一个稍大的高斯核模糊,让圆边缘变成一条有一定宽度的过渡带,这样梯度方向是多个像素共同计算的,抗噪声能力会强很多。实测下来,9×9 核比 5×5 核在圆心稳定性上改善明显。还有一个偏门技巧:在不改变图像内容的前提下,对灰度图做一次轻微的膨胀,有时也能压低孤立噪声点对梯度方向的干扰。
4.4 实时场景提速:运算量与精度怎么权衡
圆检测如果上实时视频流,最容易遇到问题是帧率不够。其实霍夫圆检测慢,往往不是因为算法本身慢,而是因为搜索空间太大。一个 1280×720 的图像,半径范围 10 到 300,累加器会反复扫描很多不必要的位置。
提速手段按性价比排序:第一,限制半径范围,这是效果最明显的。哪怕只是把 maxRadius 从 300 缩到 150,运算量都能差几倍。第二,缩小输入图像。比如把长边缩到 640,检测完成后把圆心坐标按比例还原回去,精度损失通常很小。第三,提高 dp 值。dp=1.5 或者 dp=2 会让累加器更小,速度更快,适合先做初筛,再在 ROI 上做精检。第四,用 ROI 把检测区域裁出来,不处理整幅图。
还有一个工程上的经验:不要对每一帧都全图检测。如果你在跟踪一个运动目标,上一帧已经知道圆心位置了,下一帧完全可以只在圆心周围一个较小的区域内重新检测,这样又稳又快。
我自己在这些项目里摸索下来的最大体会是,霍夫圆检测不是一个“调几个魔法数字”就能解决的算法,它的每个参数背后都有一个具体的物理或者几何含义。你把坐标系、累加器、梯度方向这些基础概念想明白了,遇到问题就知道该从哪里入手,而不是像无头苍蝇一样反复试阈值。如果你现在正准备上手圆检测,建议先拿几张简单的合成图做实验,把每个参数单独改变一次,观察结果怎么变,用不了几个小时,你对这套机制的感觉就会完全不一样。