5.26. 查找直线和线段¶
某些场景特征不是颜色相连的区域,而是有方向的笔直边缘:地面上画的一条线、两个表面之间的接缝、打印矩形的一条边、门口的边缘。让色块检测器去找它们是个错误的问题——边缘只有一个像素宽,而色块算法想要的是有颜色的面积,得到的结果会是空的或充满噪声的。
适用于有方向边缘的检测器是 Hough 直线变换。image 模块以两种形式提供它:find_lines() 返回无限直线(每条直线横跨整幅图像);find_line_segments() 返回有限线段(每条线的端点都在帧内)。应用需要哪一种取决于所关注的边缘是横贯整帧连续的,还是只跨越其中一部分。
5.26.1. Hough 变换的工作原理¶
两种检测器共享相同的核心思想,因此值得一次性弄懂它。image 模块首先对输入运行一个 Sobel 式的边缘滤波器,按每个像素位于有方向边缘上的可能性给它打分。随后每个这样的边缘像素都会为它可能位于其上的所有直线投票。获得最多票数的直线胜出。
在 Hough 空间中,一条直线由两个数字参数化:theta,即直线的角度(0 至 179 度),以及 rho,即从图像原点到该直线的垂直距离(有符号,以像素为单位)。图像所包含的每条直线都是 (theta, rho) 空间中的一个点。输入中的每个边缘像素都会为与其位置相符的每一个 (theta, rho) 组合贡献一票——从概念上说,就是穿过 Hough 空间的一条曲线。许多这样的曲线相交的地方,许多边缘像素就在同一条直线上达成一致,而那个交点就是一个检测结果。
检测器返回 Hough 空间中票数总和超过阈值的局部极大值。每个返回的 Line 同时携带两种表示:端点形式的 x1, y1, x2, y2(对于无限直线的情况会裁剪到图像边界),Hough 形式的 theta, rho,以及分别表示尺寸和票数的 length 与 magnitude。
5.26.2. 无限直线¶
find_lines() 运行 Hough 变换并返回最强的若干直线,每条都横跨整幅图像:
lines = img.find_lines(threshold=1500, theta_margin=25, rho_margin=25)
for l in lines:
img.draw_line(l, color=(255, 0, 0))
threshold 是一条直线被接受所需的最小票数总和。票数总和累加了每个贡献像素的 Sobel 边缘幅度,因此 threshold 值越大,就要求越长或越强的边缘才能通过——这使得合适的值既取决于图像分辨率(在更高分辨率下更长的直线会累积更多票数),也取决于场景,所以必须针对具体应用进行调整。作为可供调整的粗略起点:清晰图像中一条普通直线用 1000,弱对比度或短直线用 500 或更低,而对于因边缘噪声成簇而形成误检直线的杂乱场景则用 2000 或更高。
theta_margin 和 rho_margin 控制邻近极大值的合并。单个物理边缘会在其真实的 (theta, rho) 附近产生一小簇高票数的桶,检测器会在返回之前将每一簇合并到其峰值。theta_margin=25(度)会合并方向在 25 度以内的任何峰值;rho_margin=25(像素)会合并距离在 25 像素以内的峰值。默认值是合理的;调高它们会返回更少、更明显独立的直线,调低它们会返回更多、有时重复的直线。
x_stride 和 y_stride 在投票过程中对边缘像素进行步进,方式与它们在 find_blobs() 中对像素步进的方式相同。默认值 2 和 1 适用于常见情况;调高它们会以分辨率为代价加快搜索。roi 将搜索限制在帧的某个区域内,这既缩小了返回的直线范围,也减少了工作量。
每条返回的直线都可以直接绘制:Line 对象可直接传入 draw_line(),后者会从它前面读取 (x1, y1, x2, y2) 端点字段。l.theta 是以度为单位的角度,只需一次比较即可将直线分类为水平、垂直或对角线。l.magnitude 是票数总和,可将返回的直线从最强到最弱排序。
5.26.3. 线段¶
find_lines() 是适用于横贯整帧的边缘的检测器,但许多真实边缘——打印条形码的左侧、标签的顶边、尺子的可见一侧——只跨越图像的一部分。find_line_segments() 返回端点位于帧内的有限线段:
segments = img.find_line_segments(merge_distance=5, max_theta_difference=10)
for s in segments:
img.draw_line(s, color=(0, 255, 0))
线段检测器直接沿着有方向的边缘像素进行追踪,而不是在 Hough 空间中投票,其结果是一组短小的笔直走向。merge_distance 设置两段共线的短走向之间仍可合并为一个返回线段的最大像素间隙;max_theta_difference 设置合并器在相邻走向之间所容忍的方向角度差为多少度。宽松的合并(merge_distance=10, max_theta_difference=15)返回少量长线段,代价是有时会桥接本属于不同的边缘;严格的合并(merge_distance=0, max_theta_difference=5)返回许多短线段,并让应用在 Python 中自行整理它们。
结果对象与 find_lines() 返回的是相同的 Line 类型,具有相同的属性,因此流水线可以通过同一段下游代码路径处理任一种检测结果。唯一的实际区别在于,线段的端点是图像中直线的实际两端,而无限直线的端点则是直线与图像边界相交的位置。
5.26.4. 何时使用哪一种¶
在这两个方法之间做选择归结为一个问题:应用是否关心直线在哪里结束?
当答案是否定时,find_lines() 是合适的工具。循线机器人需要知道线朝哪个方向走以及它在哪里穿过帧的底部;线本身一直延伸到地平线之外。地平线检测器想要图像中最强的有方向边缘;它不需要知道地平线在哪里结束。
当答案是肯定时,find_line_segments() 是合适的工具。识别打印矩形的四条边需要四段端点已知的线段。跟踪指向显示屏的手指意味着跟随一段短线段,其端点是手指的指尖和指根。测量可见划痕的长度需要该线段在像素中的实际范围。
两种检测器有一个共同的限制:它们都需要对比度。它们所依赖的 Sobel 边缘滤波器响应的是亮度梯度;一条与等亮度背景相对的彩色边缘(同样亮度的绿墙上的一条红线)不会产生梯度,也就不会有检测结果。当这种情况在实践中出现时,解决办法是在搜索前先把单个 LAB 通道提取为具有合适对比度的灰度图像——选取 b 通道的 to_grayscale() 可以在仅凭亮度通道一片平坦之处将红色从绿色中分离出来——然后将该通道图像交给直线检测器。