简介:这份 MATLAB 工具箱围绕 LSD 直线段检测算法整理,面向图像分析、机器视觉、自动驾驶与无人机导航等方向的研究人员和开发者,用于在 MATLAB 项目中快速集成直线检测能力。该算法通过边缘像素的梯度方向一致性寻找直线段,对光照变化、模糊和噪声有一定鲁棒性,适合作为算法学习和工程验证的基础材料。压缩包共 186 个文件,约 19.28MB,包含 38 个 .m 脚本、41 个 C 源码、22 个头文件及少量 C++ 文件,另有 59 张 png 示例图、html 说明页和配置文件,兼顾核心函数、底层实现、参数配置与效果展示。已有 1011 人学习,说明它在直线检测入门与项目实践中有一定参考价值。资源提供 LSD 核心函数、reco_toolbox 配置说明、示例与许可文件,并附带 C/MEX 源码,便于读者理解算法实现、调整线段长度与角度等参数,并在不同图像上评估检测效果、排查配置问题。
1. LSD直线检测在MATLAB里到底能干什么
厂房里那台相机拍出来的钣金件,4000×3000,边缘像素几十万。用 Hough 变换做直线检测,峰值图上密密麻麻一堆点,阈值调高就漏检,调低就满屏假线,调到最后自己也不知道哪个才对。LSD 直线检测要解决的就是这件事——它不在参数空间里投票,而是从像素梯度出发做区域生长,输出的每条线自带起点、终点和长度,不需要你替它猜峰值。落到 MATLAB 里,常见做法是把一个 LSD 后端包成函数,输入灰度矩阵、输出 N×4 的线段矩阵,剩下的判据、合并、过滤全在 M 代码里写,改一行看一次结果,调试成本比在 C++ 里重新编译一轮低得多。这篇写给已经有一批图要出结果、但链路还没跑顺的人:先把内部四个环节讲透,再给能直接抄的预处理、批处理、后处理脚本,最后把参数和踩过的坑一条条摊开。
2. LSD的内部四个环节:从梯度伪排序到NFA验证
2.1 为什么Hough变换在这类图上会翻车
Hough 的思路是把每条过边缘点的可能直线映射到 (ρ, θ) 参数空间,让所有边缘点投票,票数高的格子代表一条线。它在理想二值边缘图上很好用,但工程图上有几件事会把它拖垮。
第一是阈值不可迁移。投票峰值的高度直接取决于这条线有多少边缘点,同一条边在亮区和暗区边缘点数量差好几倍,一个全局阈值不可能同时照顾两头。第二是参数空间分辨率两难,θ 分得细,算得慢还容易把一条线摊成好几个峰;θ 分得粗,角度精度就掉到几度,做尺寸测量根本不够用。第三是相邻平行线在参数空间会糊在一起,比如金属件的两侧边,间距只有十几像素,峰值互相干扰,分不开。
LSD 换了个思路:不做全局投票,而是在图像内部按梯度幅值从强到弱依次处理像素,用水平线角度一致性做区域生长,长出来的区域再用矩形近似,最后用一个统计判据决定这条线要不要。整个过程没有随机数,同一张图跑两次结果完全相同——这一点对做回归测试特别值钱,Hough 换个离散化方式结果就变了,LSD 不会。
2.2 伪排序、区域生长与矩形近似:LSD到底在画什么
把一次 LSD 调用拆开,内部大致是这么四步,每一步都对应一个你能调或者必须知道的量。
第一步:降采样 + 高斯预平滑。图像先按 scale 比例缩小(常见默认 0.8),缩之前做一次高斯滤波,sigma 一般跟 scale 绑定。这一步既是提速,也是抑制噪声,代价是短线段和小结构会一起被抹掉。scale 调小等于降得更多,速度和稳定性上去,细节下来。
第二步:梯度与水平线角度。在 2×2 邻域上算梯度 (gx, gy),得到幅值和水平线角度 atan2(gx, -gy)。注意是水平线角度,不是梯度方向,两者差 90 度;后面说的角度容差都是指这个水平线角度。理解错了这一层,看参数文档会一直别扭。
第三步:伪排序 + 区域生长。把梯度幅值量化成若干个桶(常见实现用 1024 个),按桶从高到低处理,同一桶内顺序无关紧要——这就是“伪排序”,目的是避免真排序的 O(n log n) 开销。从当前未使用的像素出发,向外扩张,只接纳水平线角度与当前区域主方向差在角度容差内(默认常取 22.5 度,即 π/8)且状态未使用的邻居。
第四步:矩形近似与密度约束。生长出的区域用一个最小外接矩形描述:质心、主方向、长度、宽度。然后算这个矩形里“方向对齐”的像素数占矩形面积的比例,也就是密度 D。D 小于阈值(常见 0.7)说明这个矩形太稀疏,把它沿着主轴切断、重算,直到满足或退化成一小段。
第五步(很多封装会藏起来):NFA 验证。对每条候选矩形统计对齐像素数 k,算随机图像中出现同样 k 的概率,再乘上整幅图可能出现的矩形总数,得到虚警数 NFA。判据是 log10(NFA) < 0 时认为这条线不是噪声凑出来的。这一步是 LSD 不需要人工设边缘阈值的根本原因,也是它在低对比度图上偶尔“什么都不给”的原因。
这五步里,你真正能在调用层改的只有 scale、sigma、角度容差、密度阈值和长度过滤,其余都在后端实现里。所以调参别乱试,先把这几个量的物理含义对上号。
2.3 最小可复现:在MATLAB里跑通第一次LSD
下面这段是最小可用调用,假设你已经把某个 LSD 后端(自写的 mex、纯 M 实现、或某个公开移植)封装成lsdDetect,接受灰度图加名称-值参数,返回 N×4 的[x1 y1 x2 y2]。
% 最小调用:读图 -> 灰度 -> LSD -> 画回原图 I = imread('part_01.png'); if size(I,3) == 3 I = rgb2gray(I); % LSD 只看单通道,彩色先转灰度 end G = im2single(I); % 后端按 [0,1] 浮点处理,uint8 直接传会出问题 segs = lsdDetect(G, ... 'Scale', 0.8, ... % 内部降采样比例,1.0 表示不缩 'Sigma', 0.6, ... % 降采样前的高斯平滑强度 'MinLength', 12, ... % 长度小于该值的段直接丢弃 'MergeTol', 2.0); % 共线相邻段的合并距离容差(像素) fprintf('检出线段 %d 条\n', size(segs,1)); figure('Color','w'); imshow(G); hold on; for k = 1:size(segs,1) plot(segs(k,[1 3]), segs(k,[2 4]), 'r-', 'LineWidth', 1.2); end hold off;逻辑说明:im2single把 0–255 的整数映射到 0–1,这一步不做,后端算出来的梯度幅值会整体偏大,伪排序的分桶全部挤在高端,区域生长的顺序完全乱掉,结果就是线段又碎又乱。segs的每一行是[x1 y1 x2 y2],坐标是 MATLAB 的 1-based 像素索引,不是 0-based,后面画图和测量都按这个来。
参数说明:Scale从 1.0 往下调会同时提速和平滑,0.8 是常见起点;Sigma指的是缩小之前那次高斯的强度,很多实现内部会取0.6/scale,你传 0.6 而 scale 传 0.8,实际生效的平滑会比 0.6 大一点,这点在比较不同图的参数时容易忽略;MinLength是后处理过滤,不是 LSD 内部逻辑,传 0 就会看到大量几像素的碎线;MergeTol也是后处理,单位是像素,指两条角度相近的线段在垂直方向上偏离多少以内算同一条。
跑通之后不要急着调参,先把同一张图在 scale 取 0.5、0.8、1.0 三档各跑一遍,把线段数、最长线段长度、平均线段长度记下来。这三个数会告诉你这张图的“细节密度”在什么档位,后面批量处理时起点就有依据了。
3. MATLAB侧的工程封装:预处理、批处理、线段表
3.1 预处理三个动作决定后面一半的稳定性
单张图跑通只是开始,一张图好看不代表一百张图能用。工程上真正吃掉时间的,是预处理不统一导致的结果飘。
灰度化不要只用rgb2gray。它按固定加权系数合并三通道,对彩色边缘的对比度是平均化的。如果工件是红色标记线画在灰色底上,红色通道和灰度通道的梯度强度能差一倍,弱边直接消失。我一般会同时算rgb2gray和单通道,各跑一遍,选线段数更稳定、长线更完整的那一路。这不是玄学,是通道选择问题,值得花十分钟验证一次。
去噪要克制。LSD 内部已经有一次高斯,你在外面再加一次imgaussfilt,等效平滑强度翻倍,短线全没了。噪声大的图我一般只在外面加中值滤波medfilt2(G, [3 3]),它对椒盐噪声有效,对高斯噪声的过度平滑比再加一层高斯轻。
尺寸归一化是最大的提速项。4000×3000 直接跑,一张图几秒;长边缩到 1200,同样的图零点几秒,而且因为降采样本身带平滑,线段数往往只掉几个百分点。代价是短于十来个像素的结构会丢,做小尺寸缺陷检测时这条不能踩。
% 统一的预处理入口:所有图走同一条路,保证结果可比 function G = prepGray(I, longSide) if size(I,3) == 3 I = rgb2gray(I); end G = im2single(I); if nargin > 1 && ~isempty(longSide) s = longSide / max(size(G)); if s < 1 G = imresize(G, s, 'bicubic'); % 缩小用 bicubic,比默认的更快也更平滑 end end if max(G(:)) > 0 G = G / max(G(:)); % 归一到 [0,1],消除曝光差异 end end逻辑说明:归一化到最大值为 1,是为了让不同批次、不同曝光的图在梯度幅值量级上对齐。LSD 的伪排序是按幅值分桶的,量级不统一,桶的分布就偏了。imresize用 bicubic 而不是默认的 bicubic 之外选项,是因为缩小场景下它比最近邻平滑得多,能避免锯齿在梯度图上制造出假的边缘方向。
参数说明:longSide传空表示不缩放;工业件我一般取 1200,文本类图像取 1600,纹理密集的布料取 800——纹理越密,越需要强降采样把纹理压掉,只留结构边。
3.2 批量脚本:从文件夹跑到N×4线段矩阵
单张调试和批量出结果之间隔着一段体力活,一次写对能省掉后面反复重跑。
% 批量:把整个文件夹跑成一张 CSV 线段表 imgDir = fullfile(pwd, 'images'); outFile = fullfile(pwd, 'segments.csv'); files = dir(fullfile(imgDir, '*.png')); opt = {'Scale', 0.8, 'Sigma', 0.6, 'MinLength', 12, 'MergeTol', 2.0}; fid = fopen(outFile, 'w'); fprintf(fid, 'file,idx,x1,y1,x2,y2,len,deg\n'); % 表头,先写死列名 for i = 1:numel(files) I = imread(fullfile(imgDir, files(i).name)); G = prepGray(I, 1200); % 统一走预处理入口 segs = lsdDetect(G, opt{:}); % 参数在循环外展开,避免重复解析 for k = 1:size(segs,1) dx = segs(k,3) - segs(k,1); dy = segs(k,4) - segs(k,2); len = hypot(dx, dy); deg = atan2d(dy, dx); fprintf(fid, '%s,%d,%.2f,%.2f,%.2f,%.2f,%.2f,%.2f\n', ... files(i).name, k, segs(k,1), segs(k,2), segs(k,3), segs(k,4), len, deg); end end fclose(fid);逻辑说明:参数元胞opt放在循环外,一是避免每张图重复解析名称-值对,二是保证所有图用完全相同的参数,出问题时能一眼看出是图的问题还是参数的问题。CSV 里存len和deg是为了后续不用回读图像就能做统计和筛选,长线占比、角度分布这两个指标基本能判断一批图的成像质量是否稳定。
参数说明:%.2f的精度对像素坐标够用,但如果你后面要做亚像素拟合,坐标别在 CSV 里截断,改成%.4f或者直接存mat。文件名里带逗号会让 CSV 错列,批量前先扫一遍文件命名,这是最常见也最容易被忽略的低级翻车。
3.3 线段后处理:合并共线段、按长度与角度裁剪
LSD 输出的线段是“区域生长”的直接结果,一条物理上连续的边经常被切成几段,尤其是光照有渐变的时候。后处理要做的就两件事:把该连的连起来,把不该留的删掉。
function segsOut = mergeCollinear(segs, angTolDeg, distTol) % MERGECOLLINEAR 合并共线的相邻线段,返回新的 N×4 线段表 segsOut = segs; changed = true; while changed changed = false; n = size(segsOut, 1); kill = false(n, 1); for i = 1:n-1 if kill(i), continue; end for j = i+1:n if kill(j), continue; end if isSameLine(segsOut(i,:), segsOut(j,:), angTolDeg, distTol) P = [segsOut(i,1:2); segsOut(i,3:4); ... segsOut(j,1:2); segsOut(j,3:4)]; best = 0; bi = 1; bj = 2; for p = 1:4 for q = p+1:4 d = norm(P(p,:) - P(q,:)); if d > best, best = d; bi = p; bj = q; end end end segsOut(i,:) = [P(bi,:), P(bj,:)]; % 取最远端点对作为合并结果 kill(j) = true; changed = true; end end end segsOut(kill, :) = []; end end function tf = isSameLine(a, b, angTol, distTol) % 角度差 + 两端点到对方延长线的距离,两条都满足才算共线 angA = atan2d(a(4)-a(2), a(3)-a(1)); angB = atan2d(b(4)-b(2), b(3)-b(1)); dAng = abs(mod(angA - angB + 90, 180) - 90); % 折到 [0,90],方向无关 if dAng > angTol, tf = false; return; end nA = [-sind(angA), cosd(angA)]; % a 的单位法向量 d1 = abs(dot([b(1)-a(1), b(2)-a(2)], nA)); d2 = abs(dot([b(3)-a(1), b(4)-a(2)], nA)); tf = d1 < distTol && d2 < distTol; end逻辑说明:合并用 while 循环反复扫,因为一次合并后新线段可能又能和第三段合并,单趟扫不干净。角度差用mod(..., 180)折叠,是为了让 179 度和 1 度判为相近——线段的两个端点顺序是任意的,不折叠就会出现“同一条水平线,一段朝右一段朝左,角度差 179 度”这种判断错误。合并结果取四个端点中距离最远的一对,而不是简单拼成a(1:2)到b(3:4),后者在 b 完全落在 a 内部时会得到一个缩短的线段。
参数说明:angTolDeg一般取 2–5 度,太小合不上,太大斜边和水平边会被误合;distTol是像素单位,取 2–3 像素比较稳,图像缩放过就按缩放比例同步放大。
过滤这段更短,但顺序很重要——先合并再过滤,否则合并后的长线可能被提前删掉:
% 过滤:先合并,再按长度和角度裁剪 segs = mergeCollinear(segs, 3, 2.5); dx = segs(:,3) - segs(:,1); dy = segs(:,4) - segs(:,2); len = hypot(dx, dy); deg = atan2d(dy, dx); keep = len >= 25 & abs(deg) < 15; % 只要近水平、长度 >= 25 像素的线 segsKept = segs(keep, :); fprintf('合并后 %d 条,过滤后 %d 条\n', size(segs,1), size(segsKept,1));逻辑说明:abs(deg) < 15是场景相关的,做钣金水平边就写窄一点,做自由形状就放宽或改成按主方向聚类。长度阈值 25 像素是个经验值,缩放比变了要跟着变,否则同一套参数在两种分辨率上给出的线段数会差一倍。
4. 调参现场:scale、sigma、密度阈值到底怎么配
4.1 scale与sigma是一对绑在一起的参数
这两个参数不能分开讨论。scale 决定内部图像缩小多少,sigma 决定缩小前的平滑多强,很多时候你调 sigma 的直觉是“让边缘更干净”,实际效果却是“把弱边整条抹掉”,因为平滑发生在缩小之前,强度会被后续的降采样再放大一次。
| 参数 | 常见默认 | 作用位置 | 调大的后果 | 调小的后果 | 实用区间 |
|---|---|---|---|---|---|
| Scale | 0.8 | 内部降采样比例 | 更快、更稳,短线丢得多 | 更慢,细节多,碎线多 | 0.5 ~ 1.0 |
| Sigma | 0.6 / scale | 降采样前高斯 | 噪声少,弱边缘一起丢 | 弱边保留,噪声线增加 | 0.4 ~ 1.2 |
| 角度容差 | 22.5° | 区域生长 | 线更完整,易跨边界误连 | 线更碎,细节保真 | 15° ~ 30° |
| 密度阈值 D | 0.7 | 矩形裁剪 | 稀疏线保留,假线增加 | 更严格,短斜线减少 | 0.5 ~ 0.8 |
| MinLength | 12 px | 后处理过滤 | 少而长 | 多而短 | 10 ~ 30 px |
实操上我基本固定 sigma 不动,只调 scale。因为 sigma 的效应是全局的、非线性的,很难从单张图看出调过之后批量会怎样;scale 的效应比较线性,缩得越多线段越少越长,好预测。真要处理噪声特别大的图,我的做法是先在外面做一次中值滤波,而不是去推 sigma。
4.2 角度容差与密度阈值:碎线与整线的分界
一条从亮区进入暗区的边,梯度方向会在中途缓慢扭转。角度容差太小,区域生长在这里断开,一条 800 像素的边被切出七八段;容差太大,生长会顺着转角拐到隔壁边上,把两条物理上不同的边连成一条。这两种错误在结果上很难区分,都是“看起来不对”,所以要看具体形态:断开处会有明显的长度截断和端点聚集,误连处则会出现长度异常长、角度介于两条边之间的线。
我的做法是先用默认 22.5 度跑一张典型图,把线段按长度排序,看最长的那几条是不是符合肉眼看到的物理边。如果最长线明显比真实边长,说明容差过大或在误连;如果真实长边只被检出一半,说明容差偏小或 sigma 偏大。
密度阈值 D 影响的是“一条线要不要被切断”。D 小,稀疏区域也被当成合法线段,结果是噪声区冒出一堆短线;D 大,实心区域边缘本来密度就高,不受影响,但细线、虚线会被裁掉。工业件上有反光的时候,反光带的密度介于两者之间,D 取 0.65 和 0.75 结果能差出一个数量级,这个参数值得单独做一次扫描。
4.3 长度过滤与NFA:两种删假线的思路
删假线有两条路,一条在算法内,一条在算法外。
算法内是 NFA。如果你的封装把每条线段的 log10(NFA) 一起返回了,那就有了一个不依赖场景的判据:log10(NFA) 越小,这条线越不可能是随机凑出来的。常见阈值是 0,也有实现给 -1 或 -2。这个量最大的好处是可迁移,换一张图、换一个场景,判据的含义不变。坏处是它只管“是不是噪声”,不管“是不是你要的线”——纹理区的短线同样可能有很低的 NFA。
算法外是几何过滤:长度、角度、位置、是否落在 ROI 里。这是真正能按业务裁剪的一层。做尺寸测量就按长度和角度筛,做装配定位就按 ROI 筛,做缺陷检测反而要保留短线。我一般把 NFA 过滤放在lsdDetect内部,把几何过滤放在外面单独一个函数,这样换场景时只改外面那层,后端不用动。
两条路叠加的时候注意顺序:先 NFA(后端内),再合并共线,最后几何过滤。顺序反了,合并阶段会把已经过滤掉的短线重新拼进来,或者把长线在合并前就删掉。
5. 避坑排查:返回空、碎成渣、坐标错位、纹理爆线
5.1 输出是0×4的空矩阵
现象:函数正常返回,size(segs,1)是 0,图上看不出任何标记。
原因:九成是输入数据类型或范围不对。imread读进来是uint8,直接传给按[0,1]浮点设计的后端,梯度幅值全部集中在最高几个桶里,伪排序失去意义,区域生长从错误的起点开始,最后 NFA 把所有候选都判掉了。另一成是图像本身就是低对比度的,比如均匀灰底上一条很淡的划痕,梯度幅值低于后端内部的数值下限。
解决:入口统一走im2single并归一化到[0,1],跑之前打一行fprintf('range %.3f %.3f\n', min(G(:)), max(G(:))),确认范围对。低对比度图先做imadjust或 CLAHE 拉伸,再送进 LSD。还有一个容易漏的:如果图是索引图或 16 位图,im2single不会自动缩放,得手动除以intmax(class(I))。
5.2 一条长边被切成十几段
现象:肉眼一条连续的边,结果里是十几条首尾相接的短线,每段几十像素。
原因:三个可能叠加。一是光照渐变导致水平线角度沿边缓慢漂移,累积超过角度容差后生长中断;二是预处理加了两层高斯,边缘被削弱到只剩中心一小段够强;三是 scale 偏小,降采样后这条边的有效像素太少,生长支撑不住。
解决:先确认是不是光照问题,把原图沿垂直方向做一次背景估计(大窗口均值滤波后相减),再跑一次,看段数是否明显下降。如果是,用背景校正解决,不要去动角度容差。如果段数没变,检查预处理有没有重复平滑,把外部高斯去掉再试。最后才考虑把角度容差从 22.5 调到 25–28,以及给mergeCollinear的distTol从 2.5 放到 4。
5.3 线段画回原图位置对不上
现象:线段整体偏移几个像素,或者缩放过的图上偏移按比例放大。
原因:坐标系约定不一致。大部分 C++ 后端返回 0-based 坐标,MATLAB 的plot和图像索引是 1-based,差一个像素;如果预处理做过imresize,后端返回的坐标是在缩小图上的,画到原图上必须除以缩放比例,很多人忘了这一步。
解决:在lsdDetect的封装层里统一做一次坐标转换,要么在入口加 1,要么在出口加 1,只在一个地方改,别散在调用处。缩放的情况,prepGray应该把实际缩放比例s一并返回,调用方按(x-1)/s+1映射回原图尺寸再画。这个坑的隐蔽之处在于偏移量很小的时候看起来“差不多对”,等做测量时才暴露,误差正好卡在公差边界上。
5.4 纹理区冒出一堆短斜线
现象:拉丝金属表面、布纹、树皮这些区域被检出一大片长度十几像素、角度随机分布的短线。
原因:LSD 只做统计意义上的虚警控制,不做语义判断。纹理本身的梯度很强,方向也局部一致,完全满足区域生长和 NFA 的条件,算法没有理由把它们判成噪声。
解决:优先级从高到低——先用 ROI 掩膜把纹理区排除掉,这是最干净的;排不掉就在结果上做长度和角度联合过滤,纹理线通常短且角度离散,结构线通常长且角度集中在几个值上;再不行就对纹理区先做一次同态滤波或高通抑制,把纹理的低频起伏压掉再送进 LSD。最后提醒一句,提高密度阈值 D 对纹理线有抑制作用但会误伤虚线结构,别当首选手段。
6. 进阶验证:用合成图卡误差、用重复性做回归
参数调到一个“看起来对”的状态很容易,难的是知道它到底准到什么程度。我的习惯是先造合成图把误差量出来,再拿真实图做重复性回归,两步都过了才敢往产线上放。
合成图的造法很朴素:在空白矩阵上按已知角度和已知端点画线,轻微高斯模糊制造边缘梯度,然后跑 LSD,比对角度和端点。
% 合成图验证:已知真值角度和端点,量 LSD 的误差 W = 800; H = 600; pts = [60 80; 720 160; 100 520; 700 540; 400 60]; BW = false(H, W); for k = 1:size(pts,1)-1 x = round(linspace(pts(k,1), pts(k+1,1), 4000)); y = round(linspace(pts(k,2), pts(k+1,2), 4000)); BW(sub2ind([H W], y, x)) = true; % 逐点置位,避免线宽不均 end G = im2single(imgaussfilt(double(BW), 1.0)); % 轻模糊,制造梯度厚度 G = imadjust(G); % 拉伸到 [0,1] segs = lsdDetect(G, 'Scale',0.8, 'Sigma',0.6, 'MinLength',40, 'MergeTol',2.0); trueAng = atan2d(diff(pts(:,2)), diff(pts(:,1))); % 4 段真值角度 estAng = atan2d(segs(:,4)-segs(:,2), segs(:,3)-segs(:,1)); err = zeros(numel(trueAng),1); for k = 1:numel(trueAng) err(k) = min(abs(estAng - trueAng(k))); % 每条真值找最近估计 end fprintf('角度误差 中位数 %.2f° / 最大 %.2f°\n', median(err), max(err));逻辑说明:linspace取 4000 个点再round,是为了让斜线的像素连续,不出现断点;如果只在端点之间取几十个点,斜线上会出现阶梯状空洞,LSD 会把它切成多段,测出来的误差反映的是造图问题不是
本文还有配套的精品资源,点击获取