调Canny双阈值调到第三天的时候,我承认有点魔怔了。同一组阈值,光照好的两张图几乎看不出差别,换到一张逆光的建筑照,要么屋顶轮廓整条断掉,要么墙面砖缝全被画成密密麻麻的碎线。那会儿我才真正想明白一件事:边缘检测这道题,难的地方从来不是"怎么算梯度",而是"怎么判断一条线到底算不算边缘"。传统算子里那个阈值是人拍的,而语义边界——比如人脸轮廓、遮挡交界、材质突变——压根不在梯度的强弱上体现,它藏在上下文里。
HED和RCF就是冲着这件事来的。它们把边缘检测从"算子调参"变成了"特征学习",用VGG16这种分类网络的中间层特征去预测每个像素是不是边缘。这篇文章我打算把自己从Prewitt、Sobel一路摸到HED、RCF的经验完整摊开:传统算子为什么在Prewitt这一步就埋下了局限,HED的"整体嵌套"结构到底精巧在哪,RCF又凭什么在BSDS500上把ODS从0.788推到0.806,以及训练、后处理、部署这几个环节里真正会让人卡住的细节。不管你是在做课题、复现论文,还是想把边缘检测塞进实际产品里,下面的内容应该都能省你几天时间。
1. 从Prewitt到Canny:手工算子的天花板其实早就写死了
1.1 Prewitt的3x3模板:一个"先平滑再差分"的朴素组合
很多人第一次接触边缘检测就是背Prewitt的两个卷积核,但很少有人停下来看一眼它的结构。水平方向的核是:
[-1, 0, 1] [-1, 0, 1] [-1, 0, 1]把它拆开看,其实等于一个列方向的均值核[1, 1, 1]^T和一个行方向的差分核[-1, 0, 1]做外积。也就是说,Prewitt在做差分之前,先在垂直于差分的方向上做了一次均值平滑。这就是它比单纯的[-1, 0, 1]差分更稳的原因——均值平滑能压掉一部分高频噪声。
但问题也在这:它只在一个方向上平滑,另一个方向的噪声原封不动地保留。我做过一个很直观的测试,给一张纯灰图加椒盐噪声,Prewitt出来的结果里横纹特别明显,就是因为它在水平方向没有做任何抑制。更关键的是,均值核是"不加权"的,中心像素和邻域像素贡献一样大,这在真实图像里其实不太合理——离得越近的像素影响应该越大。
1.2 Sobel只是把均值换成二项式,抗噪就上了一个台阶
Sobel的核长这样:
[-1, 0, 1] [-2, 0, 2] [-1, 0, 1]它和Prewitt唯一的区别是中间那行的权重变成了2。从信号处理的角度看,这相当于把平滑核从[1,1,1]换成了[1,2,1],而[1,2,1]是二项式系数,逼近高斯核的一阶近似。高斯核在频域上是低通滤波器里"过渡带最陡峭"的那种,所以Sobel在保留边缘的同时把噪声压得更干净。
实话说,如果只是做简单的工业流水线检测,比如传送带上零件有没有缺口,Sobel到现在依然是首选,因为它快、可控、不需要GPU。但它的天花板同样明显:边缘只有一个像素宽,方向是离散的8个方向,而且没有任何"这条边缘重不重要"的判断。
1.3 Canny补上了非极大值抑制和滞后阈值,却把选择权还给了人
Canny的贡献是把边缘检测拆成了四步流水线:高斯滤波、计算梯度幅值和方向、非极大值抑制、双阈值加滞后连接。前三步是数学上确定性的,第四步才是真正的分水岭——它第一次引入了"强边缘"和"弱边缘"的概念:强边缘直接保留,弱边缘只有在和强边缘连通时才保留。
这个设计很聪明,它让边缘具备了连续性。但致命的点也在这:高阈值和低阈值都得人工拍。我统计过自己调参的经验,同一个数据集上,最优阈值和图像的平均对比度基本是线性相关的,也就是说光照一变,参数就得重来。更要命的是,即使你把阈值调到完美,Canny也认不出"沙发和地板交界"这种靠材质而不是靠亮度区分开的边界。这就是语义边缘的盲区,也是HED和RCF后来居上的根本入口。
2. HED的核心赌注:拿VGG16的五个阶段做深监督侧输出
2.1 为什么选VGG16,而不是自己搭一个边缘专用网络
HED(Holistically-Nested Edge Detection)2015年发在ICCV上,它最大的取舍就是直接拿一个已经在ImageNet上训练好的VGG16当backbone,砍掉全连接层,只留五个卷积stage。这个选择在当时其实是有点反直觉的——边缘检测和图像分类看起来八竿子打不着,你拿一个"认猫认狗"的网络去做边缘,凭什么?
道理在于卷积网络的层级结构本身。浅层卷积学到的是类似Gabor滤波器的方向响应,这跟Sobel、Prewitt做的事情高度重合;而深层卷积学到的是物体部件、语义轮廓。也就是说,一个分类网络的中间层天然包含从低级梯度到高级语义的完整谱系,而边缘检测恰好需要的正是这个谱系。这就是迁移学习的价值:VGG16的第一个stage其实已经是一个训练好的、比手工算子更丰富的边缘滤波器组。
提示:如果你手上数据量很少(几百张),从VGG16预训练权重初始化几乎是必须的。从零训练HED,在BSDS500的200张训练图上很难收敛到论文精度。
2.2 "整体"和"嵌套"这两个词到底指什么
HED这个名字拆开看很有意思。"Holistically"指的是端到端——输入原图,输出一张和原图同尺寸的边缘概率图,中间不需要任何人工设计的后处理步骤,图像到图像,干净利落。"Nested"指的是多尺度嵌套——VGG16的五个stage,感受野逐级翻倍,浅层看到的是局部细节,深层看到的是大范围上下文。
具体做法是:在五个stage的最后一个卷积层后面各接一个侧输出分支(side branch),这个分支一般是一个1x1卷积把通道数降到1,再跟一个反卷积把特征图上采样回原图尺寸。于是你同时得到五张不同尺度的边缘图,再加上一个把它们加权融合起来的fusion输出,一共六个输出。
这个结构的精髓在于深监督(deep supervision)。六个输出全都参与损失计算,也就是说网络不能只靠最后那一张融合图交差,每一层的侧输出都必须独立地预测出合理的边缘。这相当于强迫中间层的特征也具备边缘判别能力,而不是让它们只做"给深层打工"的搬运工。
2.3 侧输出的加权融合,为什么不是简单的平均
论文里融合的方式是逐元素加权求和再sigmoid,权重初始化为1。听起来跟平均差不多,但因为每个侧输出的loss是独立的,网络会自动学着调整哪些尺度的贡献更大。实际训练完之后你会发现,中间层(conv3、conv4对应的stage)的权重往往是最大的,因为太浅层噪声太多,太深层空间分辨率丢得太狠,中间的几层在感受野和分辨率之间取得了平衡。
我自己的经验是,如果你想省事只保留三个侧输出,优先留conv2、conv3、conv4对应的stage,把conv1和conv5砍掉,精度掉得通常不超过1个点,但推理速度能快不少。这个取舍在小目标检测的预处理里特别划算。
3. RCF把每个卷积层的特征都榨干,改进远不止"更深"
3.1 HED的浪费:一个stage里只用了最后一层
如果你仔细看HED的结构,会发现一个明显的浪费:VGG16的每个stage里其实有好几层卷积,比如stage1有conv1_1和conv1_2两层,stage2有conv2_1和conv2_2。HED只用每个stage最后一层的输出接到侧分支上,前面那几层的特征直接被丢掉了。
RCF(Richer Convolutional Features)2017年发在CVPR上,作者的观察就是:每个stage里每一层卷积的特征图都有边缘信息,只是尺度和抽象程度不同,为什么不全用上?这个思路一出来,结构就变了——RCF把每个stage内所有卷积层的特征图全部收集起来,先各自用1x1卷积降到21通道,拼在一起,再通过一个融合卷积得到这个stage的侧输出。
对比一下两者的character图:
| 维度 | HED | RCF |
|---|---|---|
| 侧输出来源 | 每个stage最后一层 | 每个stage内所有卷积层 |
| 特征通道处理 | 直接1x1降到1 | 每层降到21再concat |
| 跨stage融合 | 带权重逐元素相加 | 带权重逐元素相加 |
| 损失函数 | 类别平衡交叉熵 | 类别平衡交叉熵+忽略不确定像素 |
| BSDS500 ODS | 0.788 | 0.806 |
看这个表就能明白,RCF的提升不是靠更深的网络或者更多的数据,而是把已有特征的价值榨得更干净。同一个VGG16,同样的输入,只是改了特征的收集和融合方式,ODS就涨了近2个点,这在边缘检测这个已经很卷的领域里算是很大的进步了。
3.2 21通道这个数字不是随便定的
很多人会问,为什么每个stage要降到21通道,不是16或者32?这个数字在RCF论文里有明确交代:它是实验调出来的。通道数太少(比如8),不同层的特征融合时会互相干扰,信息被过度压缩;通道数太多(比如64),模型参数上去了,精度提升却很有限,而且容易过拟合。
我复现的时候试过16和32,16的ODS大概掉0.5个点,32基本持平但显存占用多了一截。所以21这个数其实处在"够用且省"的甜蜜点上。这种细节在论文里往往一笔带过,但你真正上手训练的时候就会发现它挺重要。
3.3 RCF对不确定像素的处理,才是它涨点的隐藏原因
除了结构,RCF在损失函数上做了一个很关键的改动。BSDS500这个数据集有个特点:每张图由多个标注者独立标注,所以同一个像素可能有人标成边缘、有人标成非边缘。HED的处理比较粗暴,它基本是"非黑即白",把所有标注都当成确定标签。
RCF的做法更细腻:它把标注为边缘的像素当正样本,标注为非边缘的当负样本,而那些标注者之间有分歧的像素(通常标记为0.5)直接忽略,不计入损失。这个改动看起来不起眼,但它避免了网络在"本来就说不清"的地方被强行拉向某一个答案。
我实测过,光是加上这个忽略逻辑,在BSDS500上的F-measure就能涨0.3到0.5个点。这部分的收益其实比很多人想象的大,因为它本质上是在教网络"不知道的时候别乱猜",这比强行拟合噪声标签要健康得多。
4. 训练一个边缘检测网络:标注、损失和数据增强里的隐形坑
4.1 类别不平衡不是用"加权"两个字就能解决的
边缘像素在整张图里只占很小的比例,通常是5%到10%,剩下全是背景。如果直接用普通的交叉熵,网络很快就会学会"全预测成非边缘"这个偷懒策略——准确率看着有90%以上,但F-measure接近0。
HED和RCF用的都是类别平衡的交叉熵,正样本的权重β设为|Y-|/|Y|,负样本权重设为|Y+|/|Y|。翻译成人话就是:谁少给谁加权。边缘像素只占5%,那它的权重就是0.95,背景像素占95%,权重就压到0.05。这么一调,两类对损失的贡献就平衡了。
RCF的损失实现,我一般这么写:
import torch def balanced_bce(pred, label, beta=0.95, eps=1e-6): # pred: (N,1,H,W) 已经过sigmoid,取值0~1 # label: (N,1,H,W) 取值 {0, 0.5, 1},0.5 表示标注不确定,忽略 mask_pos = (label == 1).float() mask_neg = (label == 0).float() loss_pos = -(mask_pos * torch.log(pred + eps)).sum() loss_neg = -(mask_neg * torch.log(1 - pred + eps)).sum() n_pos = mask_pos.sum() n_neg = mask_neg.sum() loss = (beta * loss_pos + (1 - beta) * loss_neg) / (n_pos + n_neg + eps) return loss这里有个坑我得提醒:beta不能设成0.5。有人看到"类别平衡"就下意识以为平衡就是各0.5,结果训练出来的边缘图非常糊。原因很简单,0.5是"按样本数平衡",而边缘检测里正样本本来就少,网络会倾向于把边界画粗来提升召回。0.9到0.95是比较稳的区间,我一般在0.92到0.95之间选。
4.2 多尺度侧输出要分别监督,别只盯着融合图
训练的时候,六个输出(HED是5个侧输出+1个fusion,RCF同理)都要算loss,最后求和或加权求和。我见过有人图省事只对fusion输出算loss,结果精度掉得很难看,基本白训。
原因在于深监督的作用是给中间层提供梯度捷径。如果只对最后那层算loss,梯度要一层层回传,中间层学到的特征会退化成"随便提取点什么,反正后面有人收拾"。加上侧输出的loss之后,每一层都被迫自己把边缘预测做对,这在训练初期尤其重要——它能加速收敛,也能减轻梯度消失。
侧输出的loss权重我一般设成1,fusion输出的权重设成1。也见过给fusion更高的权重(比如2),但我自己试下来差别不明显,反而1:1更稳。
4.3 数据增强:旋转和翻转是性价比最高的两种
BSDS500只有200张训练图,这点数据量直接训VGG16肯定过拟合。HED和RCF都用了很朴素的增强方式:旋转90度、180度、270度,加上水平翻转和垂直翻转。这样一张图能变成8张,200张变1600张。
这里有个细节要注意:旋转必须是90度的整数倍。如果你做任意角度的旋转,边缘会出现插值造成的模糊,而这些模糊本身会被网络当成"边缘"学进去,反而污染训练。90度倍数旋转不涉及插值,像素是严格对应的,标签图也能同步旋转,最干净。
至于颜色抖动、高斯噪声这类增强,我个人不太推荐用在边缘检测上。因为边缘检测的学习目标就是"哪个像素是边界",颜色抖动会影响亮度梯度,可能把原本清晰的边缘破坏掉,标签却没跟着变,这就制造了矛盾样本。
5. 从概率图到可用边缘:阈值化、细化与评估指标的坑
5.1 输出的是概率,不是边缘,这一步千万别忘
HED和RCF的输出是每像素0到1的边缘概率,要想得到二值化的边缘图,必须阈值化。这里有两个思路:一个是固定阈值(比如0.3或0.5),一个是对每张图单独选最优阈值。
固定阈值适合做实时系统,胜在简单一致。但你会发现在某些图上,0.5会把弱边缘全砍掉,那张图的召回率就很低。所以评估的时候要用两种指标:
- ODS(Optimal Dataset Scale):整个数据集用同一个阈值,选让F-measure最大的那一个。
- OIS(Optimal Image Scale):每张图各自选最优阈值。
ODS衡量的是"一套阈值能不能通用",OIS衡量的是"模型本身的分辨能力"。论文里通常两个都报。如果ODS和OIS差得特别大,说明你的模型对阈值的鲁棒性不够,实际部署时就有麻烦。
5.2 边缘细化:要不要做,做了会不会更好
深度学习出来的边缘通常会比Canny的结果宽一些,大概是2到3个像素。如果你下游任务(比如轮廓拟合、形状匹配)对边缘宽度敏感,就得做细化。常见做法有两种:
一是非极大值抑制,和Canny的思路一样,沿着梯度方向检查一个像素是不是局部最大,不是就压制掉。二是形态学细化,用骨架化算法把粗边缘收敛成单像素线。
这里有个取舍我得说清楚:细化能让边缘变细,但会丢掉一部分弱边缘,召回率通常会掉。所以评估的时候,论文里报的F-measure基本都是未细分化的原始输出。你要是在自己项目里加了细化再对比论文数字,会发现怎么都对不上,就是这个原因。
用MATLAB的话,edge函数里的Canny/Sobel是传统方法,深度学习模型的输出则直接用imbinarize或im2bw做阈值化就行,不需要再走一遍Canny的流程,否则相当于做了两次边缘检测,反而会漏掉信息。
5.3 ODS/OIS之外,AP这个指标更值得看
除了ODS和OIS,还有个指标叫AP(Average Precision),它衡量的是边缘预测的排序质量。简单说,AP看的是"模型给出的高概率像素是不是真的是边缘",对排序敏感,不受阈值影响。
我自己的习惯是三个都看:ODS和OIS看整体水平,AP看模型的置信度排序是否合理。如果一个模型ODS不低,但AP明显偏低,说明它的概率分布可能被压缩在了某个区间,这种模型换到新数据上往往会崩。
6. 塞进实时系统:轻量化和FPGA部署里的真实取舍
6.1 VGG16在嵌入式设备上跑不动,这不是夸张
VGG16有大约1.38亿个参数,HED和RCF都基于它。在桌面GPU上单张图推理大约100到150毫秒,勉强算"接近实时",但放到边缘设备或者FPGA上就是灾难。所以在实际产品里,直接照搬论文结构基本行不通,必须做轻量化。
常见的几条路:
- 换backbone:把VGG16换成MobileNet或者ResNet的轻量变体,参数量能降到原来的十分之一,精度通常掉2到4个点。
- 通道剪枝:分析每个卷积层通道的重要性,砍掉冗余通道,再微调恢复精度。
- 定点量化:把FP32权重转成INT8,模型体积缩到四分之一,速度提升2到4倍,精度掉通常在1个点以内。
6.2 如果只能上FPGA,传统算子依然是现实选择
有个事实得承认:在纯FPGA上实时跑深度学习边缘检测,工程量非常大,而且往往得不偿失。VGG16的卷积层需要大量乘法器和片上存储,即便量化到INT8,也很吃BRAM资源。而很多实际场景(比如工业质检、车道线检测的粗定位)其实不需要那么强的语义能力。
所以如果你手上是纯FPGA平台,我建议还是走传统算子的硬件流水线,Sobel或者Canny的FPGA实现已经很成熟:
一是行缓存(line buffer),用几行BRAM把图像缓存起来,滑动窗口并行计算,避免反复访问外部存储。二是流水级设计,把高斯滤波、梯度计算、非极大值抑制、阈值判断做成多级流水,每个时钟周期出一个像素,吞吐率能轻松做到1080p@60fps。三是定点数精度,FPGA上浮点资源稀缺,通常用16位定点就够,梯度幅值计算用平方根近似或者干脆用绝对值求和替代,精度损失可以接受。
我实际调试FPGA Canny的时候踩过一个坑:非极大值抑制的角度量化。图像梯度方向本来是连续角度,硬件里通常量化成4个或8个方向。方向量化越粗,边缘越容易断裂,这个参数需要在资源和效果之间反复调。4方向省资源但断线多,8方向效果好但查找表复杂度翻倍,我最后一般选8方向。
6.3 混合方案:低功耗端侧用轻量网络,后端做精细处理
我见过比较实用的架构是两级:前端用轻量模型或者FPGA上的传统算子做粗筛,只检测明显的边缘;后端在算力充足的服务器上跑完整的HED或者RCF,做精细的语义边缘补充。这样既保证了实时性,又能在需要的时候拿到高质量边缘。
这个方案在自动驾驶的感知预处理里其实挺常见,车道线、可行驶区域这些强边缘先在前端快速框出来,语义级别的物体轮廓再交给后端慢慢算。当然,这套架构的调度和带宽设计本身也是个大工程,不是简单把两个模型拼在一起就行。
最后分享一个我自己的体会:边缘检测这个方向,从Prewitt到RCF,本质上是在回答同一个问题——"什么样的变化才值得我们关注"。手工算子回答的是"变化有多剧烈",HED和RCF回答的是"变化有没有意义"。理解了这条主线,你在看新论文、选方案的时候,就不会被一堆结构图和公式绕晕,而是能一眼看出它在解决哪个层面上的问题。至于先上哪个,我的建议永远是:先跑通Canny把数据摸熟,再上HED或RCF做语义增强,别一上来就冲着最新的结构去,那样很可能连数据里的噪声都没搞清楚。