简介:HED(超柱面边缘检测)是2015年提出的深度学习边缘检测算法,借助卷积神经网络多层特征融合,相比Canny、Sobel等经典算子,能捕获更完整的语义边界。这个轻量压缩包体积仅2KB,包含3个文件:Python调用脚本、Caffe部署的prototxt网络结构、以及预训练权重下载脚本,便于快速搭建测试环境。资源面向具备基础Python知识、想直观理解端到端边缘预测流程的开发者与研究人员,可直接在Caffe框架下加载模型,对图片输出多尺度融合的边缘图。已有1240人学习下载,适合作为复现论文、算法对比或课程设计的入门起点,也可借此探索HED的多分支融合细节。
1. HED边缘检测:先别急着上Canny,2015年的模型现在仍然是默认首选
做图像配准、线稿提取或者分割预处理的时候,很多工程师第一反应是Canny,第二反应是Sobel/Prewitt,但真正拿到复杂纹理图、弱边缘图或者光照不均匀的图时,这类经典算子立刻露馅——边缘断裂、噪声毛刺、参数玄学。HED(整体嵌套边缘检测,Holistically-Nested Edge Detection)是2015年提出的深度边缘检测网络,它一次性解决了“边缘该在哪”和“边缘不该在哪”两个问题,用五个不同感受野的侧输出把边缘从像素级到语义级全兜住。它适合三类人:做图像配准和特征提取的、做分割前的边缘先验的、以及想在FPGA或嵌入式设备上跑轻量边缘检测的。这篇笔记就把HED从原理、训练到部署的坑一次讲完。
2. 看懂HED的四个核心设计:侧输出、多尺度、深度监督和融合
2.1 从Prewitt边缘检测原理到HED:边缘检测到底难在哪
经典边缘检测全部依赖局部梯度。Prewitt边缘检测原理是固定的3×3卷积核,对水平、垂直方向做差分近似,本质上是带权平滑加差分,抗噪能力比Roberts好一点,但遇到灰度渐变区或者纹理密集区,输出要么断裂、要么全是假响应。Canny用高斯平滑加双阈值滞后连接改善了连通性,但阈值人工调,尺度固定,全局只有一种分辨率。你调好参数处理一张图可以,换个场景全废。
HED的出发点完全不同:它把边缘检测当成一个密集像素分类问题,直接预测每个像素是边缘的概率,而不是去算梯度。五个侧输出分别对应VGG16的五个stage,每个stage的感受野从小到大,天然覆盖了不同尺度的边缘。细边缘(头发、裂纹)在小感受野里被保留,粗轮廓(物体边界、阴影边界)在大感受野里被强化。最后融合层把五个分数图整合成一个统一输出,这就是“整体嵌套”的含义。
我从实践角度给一个选型结论:如果任务是高精度轮廓提取、遥感分割预处理、图纸矢量化,HED值得投入;如果只是做简单的实时监控轮廓,帧率要求极高,那经典算子更快。表里列一下对比逻辑:
| 方案 | 尺度 | 抗噪 | 边缘连续性 | 需不需要训练 | 输出 |
|---|---|---|---|---|---|
| Prewitt | 单一固定核 | 差 | 差 | 否 | 梯度幅值图 |
| Canny | 单一固定核+双阈值 | 中 | 中等,取决于阈值 | 否 | 二值图 |
| HED | 五个stage多尺度 | 好 | 好,天然连续 | 需要,可微调 | 边缘概率图 |
2.2 VGG16去掉全连接层,五条侧输出在干什么
HED的主干就是VGG16去掉全连接和最后的池化层,保留前五个卷积stage。每个stage最后的特征图接出一个侧输出层,侧输出由一层1×1卷积把通道数压到1,再接双线性上采样恢复到输入尺寸。上采样层论文里用的是反卷积,但反卷积初始化成双线性插值,后续也可以微调。用PyTorch复现时,直接F.interpolate(..., mode='bilinear')就行,效果和反卷积差异极小。
五个stage的配置如下表,我一般直接用torchvision里VGG16的特征提取部分改:
| stage | 对应VGG16层 | 输出通道数 | 特征图大小(相对输入) | 感受野特点 |
|---|---|---|---|---|
| side1 | conv1_2 | 64 | 1/2 | 局部细节,感受野小 |
| side2 | conv2_2 | 128 | 1/4 | 短纹理 |
| side3 | conv3_3 | 256 | 1/8 | 中等结构 |
| side4 | conv4_3 | 512 | 1/16 | 大块区域轮廓 |
| side5 | conv5_3 | 512 | 1/16 | 全局语义 |
每条侧输出都做一次有监督训练,也就是说一张训练图要同时监督五条边。这就是“深度监督”的核心:梯度从五个不同尺度回传到主干,主干被迫学到既保留细节又理解语义的特征。融合层把五条分数图按通道拼接,再过一层1×1卷积,输出最终边缘图。推理时可以直接用融合层输出,也可以把五个侧输出加融合输出共六张图取平均,后者在BSDS500上评估分数略高,但速度更慢。
2.3 class-balancing loss:正负样本比例悬殊时,别让loss被背景淹没
边缘像素在整张图里的占比通常只有几个百分点。HED用的不是普通交叉熵,而是类平衡交叉熵。它给每个像素加权:让少数类(边缘像素)的损失权重提高,多数类(背景)权重降低。具体做法是统计当前batch里边缘像素占比P,把权重设为1-P和P分别分配给正负样本。用PyTorch写的话,我一般直接用torch.nn.functional.binary_cross_entropy_with_logits,再手动乘一个权重矩阵,避免样本失衡。
注意:HED论文里每个side output的loss权重都是1,融合层loss权重也是1,总loss是六个loss直接相加,不需要额外调权。这个在复现时经常有人怀疑,觉得应该给融合层加大权重,实际论文验证过等权最优。
如果你自己标数据做训练,我建议保留这个类平衡机制,不要换成普通BCE——否则你训练出来的模型会倾向于把所有像素都预测成背景,因为背景占绝大多数,loss已经很低了,边缘反而学不出来。
3. 数据与代码准备:BSDS500怎么用,代码库怎么选
3.1 训练数据:BSDS500够用,但需要自己补数据增强
HED论文用的是BSDS500,结构是200张训练、100张验证、200张测试,每张图对应多个人工标注的边缘图。做边缘检测任务时,训练用的GT是“多个标注者标注结果的并集”,通常做法是对多个标注图取平均再二值化。下载下来的目录一般是train、val、test三个文件夹,加上groundTruth子目录存放.mat格式的标注文件。
读取BSDS500要注意,标注是.mat文件,里面包含多个标注者的边界图。我一般用scipy.io.loadmat读出来,遍历每一个标注者,把边界图合并成一个概率图。如果直接取某个标注者的图,边缘风格会和模型预期训练数据分布产生偏差。合并策略很简单:多张标注图取平均,再以阈值0.1~0.5做二值化,我通常用0.4,断边缘会更少。
数据增强这块,PyTorch的torchvision.transforms没有针对性方案。我习惯写一个很简单的组合:随机缩放(0.8~1.2倍)、随机旋转(±10度)、随机翻转、随机裁剪到固定尺寸(如480×480)。编写代码时,图片和GT必须做完全相同的变换,不能分别用两套transform,否则模型会学到错位。
3.2 代码库怎么选:Caffe老项目别碰,PyTorch复现最省心
HED开源项目最早是Caffe版本,但Caffe对现代GPU和Python环境极不友好,没必要折腾环境配置。PyTorch复现项目很多,挑一个看着清爽的即可。我自己常用的结构很简单:一个hed.py定义模型,一个dataset.py处理BSDS500,一个train.py跑训练。模型定义的要点是按VGG16的五个stage取特征,而不是直接调用torchvision.models.vgg16整模型——整模型带了全连接层和分类头,只取features部分即可。
提示:不要用已经带BN层的VGG16预训练权重。HED原版用的是普通Conv+ReLU,没有BN层。如果你用了torchvision的vgg16_bn,会发现训练不稳定且收敛变慢。选不带BN的版本。
3.3 预训练权重:VGG16 ImageNet权重决定了你的起步速度
HED不能从随机初始化开始训练,边缘检测这种像素级任务从零训练收敛极慢,效果也差。必须加载VGG16在ImageNet上的预训练权重,取features部分的前13个卷积层权重来初始化主干。加载时注意两处修改:一是去掉最后的分类层对应权重,只保留卷积层;二是侧输出和融合层的权重是随机初始化的,不能强制加载。
实践里我踩过一个典型的坑:忘了删掉预训练权重的classifier部分,直接model.load_state_dict(vgg16.state_dict(), strict=False),虽然不报错,但因为strict=False而静默跳过匹配不上的层,导致侧输出层一直用随机权重跑,loss居高不下。所以代码里要显式只加载features字段。
加载完成后可以冻结前两个stage的权重,只训练后三个stage和侧输出层。原因很简单:边缘检测的底层特征(颜色、局部梯度)和分类任务差别不大,浅层不需要重新学,冻结能显著降低显存占用,训练速度也快不少。这个策略在BSDS500这样的小数据集上几乎不会掉精度。
4. 用PyTorch跑通HED训练:完整步骤、命令与参数说明
4.1 最小模型定义:三个月后回看也能秒懂的写法
下面这段是我简化过的HED主干定义,去掉花哨封装,直接用torchvision取VGG16特征,分五段切片。写代码时我特意保留了五个side的索引,方便后面调试时单独输出某一条侧结果。
import torch import torch.nn as nn import torch.nn.functional as F from torchvision import models class HED(nn.Module): def __init__(self, pretrained=True): super().__init__() vgg = models.vgg16(weights=models.VGG16_Weights.IMAGENET1K_V1 if pretrained else None) features = vgg.features # 取卷积部分,去掉全连接 # 按VGG16原始结构切出五个stage self.stage1 = nn.Sequential(*features[0:4]) # conv1_2 输出 self.stage2 = nn.Sequential(*features[4:9]) # conv2_2 输出 self.stage3 = nn.Sequential(*features[9:16]) # conv3_3 输出 self.stage4 = nn.Sequential(*features[16:23]) # conv4_3 输出 self.stage5 = nn.Sequential(*features[23:30]) # conv5_3 输出 # 侧输出:1x1卷积压缩通道为1,再接上采样到原图尺寸 self.side1 = nn.Conv2d(64, 1, 1) self.side2 = nn.Conv2d(128, 1, 1) self.side3 = nn.Conv2d(256, 1, 1) self.side4 = nn.Conv2d(512, 1, 1) self.side5 = nn.Conv2d(512, 1, 1) # 融合层:5个通道拼接后1x1卷积 self.fuse = nn.Conv2d(5, 1, 1) def forward(self, x): h1 = self.stage1(x) h2 = self.stage2(h1) h3 = self.stage3(h2) h4 = self.stage4(h3) h5 = self.stage5(h4) s1 = F.interpolate(self.side1(h1), size=x.shape[2:], mode='bilinear', align_corners=False) s2 = F.interpolate(self.side2(h2), size=x.shape[2:], mode='bilinear', align_corners=False) s3 = F.interpolate(self.side3(h3), size=x.shape[2:], mode='bilinear', align_corners=False) s4 = F.interpolate(self.side4(h4), size=x.shape[2:], mode='bilinear', align_corners=False) s5 = F.interpolate(self.side5(h5), size=x.shape[2:], mode='bilinear', align_corners=False) fuse = self.fuse(torch.cat([s1, s2, s3, s4, s5], dim=1)) return [s1, s2, s3, s4, s5, fuse]这段代码里值得解释的细节是features的切片边界。VGG16的features是一个包含13个卷积层和5个maxpool的Sequential,必须从models.vgg16实例里取出后按索引准确切片,否则stage感受野会错位。每个stage末尾自带一个maxpool,所以stage输出的特征图尺寸按比例缩小。align_corners=False是PyTorch中双线性插值的默认推荐值,配F.interpolate做上采样时,与前向坐标对齐方式有关,统一设False即可,换成True会带来轻微偏差。
4.2 训练入口:核心参数与调试技巧
训练代码核心部分是loss函数的实现。重点在于对每个side单独算类平衡交叉熵,并且类别权重要按当前batch统计。下面这段可以直接放到训练循环里:
def hed_loss(preds, targets): # preds: 模型输出的6张图列表,每个shape为(B,1,H,W) # targets: GT边缘图,shape为(B,1,H,W),边缘像素为1,背景为0 total_loss = 0.0 for pred in preds: logit = F.interpolate(pred, size=targets.shape[2:], mode='bilinear', align_corners=False) pos = targets.sum() # 边缘像素总数 neg = targets.numel() - pos # 背景像素总数 w_pos = neg / targets.numel() # 给正样本的权重 w_neg = pos / targets.numel() # 给负样本的权重 # 构建像素级权重矩阵 weight_map = torch.where(targets > 0, torch.full_like(targets, w_pos), torch.full_like(targets, w_neg)) loss = F.binary_cross_entropy_with_logits(logit, targets, weight=weight_map) total_loss += loss return total_loss这个loss设计的逻辑是:边缘像素越少,w_pos越大,网络犯错时付出的loss代价越高。PyTorch的binary_cross_entropy_with_logits里的weight参数是“逐像素权重”,和HED论文公式完全对应。如果GT边缘占比只有2%,那正样本的loss权重约是负样本的49倍。
训练入口的超参数,我推荐按这个表起步,BSDS500在单张V100或3090上都能跑完:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 裁剪尺寸 | 480×480 | 太大显存不够,太小感受野不足 |
| batch_size | 4~8 | V100用8,16G以下显存用4 |
| 初始学习率 | 1e-4 | SGD优化器配momentum=0.9 |
| 学习率策略 | 每3个epoch衰减0.1 | 10个epoch以内能收敛 |
| 冻结浅层 | 冻结stage1/stage2 | 减少约30%显存占用 |
| epoch数 | 6~10 | 超过10个epoch容易过拟合 |
4.3 踩了环境配置的坑,才知道GPU版环境怎么排查
装PyTorch时,Ubuntu20.04配深度学习环境这块翻车率极高。我写三条排查路径:先nvidia-smi确认驱动识别GPU,再python -c "import torch; print(torch.cuda.is_available())"确认PyTorch版本和CUDA兼容,最后用torch.backends.cudnn.benchmark = True加速训练。如果显存不足报CUDA out of memory,不要急着换更大的卡,先把裁剪尺寸降到384×384,batch_size降到2,再看loss是否正常。
有人会纠结深度学习里的parameter应该不是mb吧这种问题——模型参数量和显存占用确实是两个维度。HED主干VGG16大约1.34亿参数,模型文件约500MB。但训练时显存占用主要不是参数权重,而是中间特征图、梯度、优化器状态(momentum和weight decay各一份副本)。所以即使一个HED模型文件才150MB,单卡16G也可能不够训练,这时候用混合精度torch.cuda.amp能把显存砍半,代价是边缘细线的定位精度可能轻微下降。
4.4 训练阶段的反直觉判断:loss在降但边缘图越来越粗
训练过程里,我发现一个反直觉现象:总loss正常下降,但保存的预测图边缘越来越粗。原因是类平衡交叉熵对误分类的细节像素惩罚大,网络通过扩大边缘宽度来降低“边缘内部被误判为背景”的风险。解决办法是训练尾声把GT做一次形态学细化,或者推理时对边缘概率图做非极大值抑制。这不影响训练loss趋势,但直接影响最终视觉效果。
5. HED避坑指南:从训练到部署的经典翻车现场
5.1 侧输出全白或全黑,问题出在loss权重上
现象:训练第一个epoch,五个侧输出和融合层输出全为纯白或纯黑,没有任何轮廓。
原因:最常见的两个。一是GT没有归一化到[0,1]区间,直接用0和255的GT去算BCE loss,梯度方向完全错乱;二是预训练权重没有正确加载,主干全是随机权重,网络在瞎猜。
解决:先确保GT经过targets = (targets > 127).float()二值化并缩放到0/1。然后打印预训练权重加载时匹配层数量,确认features子模块的前13个卷积层权重不是随机值。最后做一个快速验证:喂一张单色图看前向能否稳定输出。
5.2 边缘断裂严重,和阈值设置没半点关系
现象:融合层输出在视觉上有连续轮廓,但二值化后断裂,密度明显低于论文效果图。
原因:推理时只用了融合层输出而没有综合利用侧输出。HED论文里评测用的最终结果是六个输出的平均,尤其是side1和side2保存了细边缘,融合层更偏向语义大轮廓。只用融合层会丢掉短纹理细节。
解决:推理时把五个侧输出和融合层输出都做sigmoid,然后六张图取均值,再做非极大值抑制细化。在测试集上这个组合比只用融合层ODS分数高约2~3个点。
5.3 边缘定位准确但都偏移了半条线宽,问题在标注合并
现象:训练后预测和GT对比没有错位,但形状整体放大或缩小了一圈,像膨胀腐蚀效果。
原因:BSDS500的.mat文件里多标注者图直接做平均再二值化会得到比实际边缘更宽的带,网络学到的是“加粗的”边缘分布。
解决:合并GT时先取平均,再做一次骨架化操作(skimage.morphology.skeletonize),把边缘带细化到单像素宽度。我实测过,这个细节对最终定位精度影响比调学习率还大。
5.4 转ONNX做FPGA部署,上采样算子报错
现象:torch.onnx.export成功,但用onnxruntime推理时报错Unsupported operator: Resize或Upsample,套到FPGA边缘检测特征提取流程时模型直接跑不起来。
原因:PyTorch新版本导出的F.interpolate在不同opset版本下对应的算子不同。老opset(<=9)用Upsample,新opset(>=11)用Resize。FPGA工具链对这两个算子的支持度不一致,通常只支持Resize。
解决:导出时显式指定opset_version=11,并在导出前将输入固定尺寸,避免动态shape。如果仍报错,自己写一个最近邻或双线性的上采样替代F.interpolate。FPGA部署时留意量化敏感层,侧输出上采样层建议保留浮点计算,只对卷积层做INT8量化。
5.5 显存明明够用,训练到一半OOM
现象:第一个epoch正常,第二个epoch中途报CUDA out of memory,但显存占用曲线一直上升。
原因:PyTorch的DataLoader在num_workers过大时,CPU预处理产生的pin_memory缓存累积,加上验证时保存的预测图没有及时释放,把显存慢慢吃满。还有一个隐蔽原因:F.interpolate在计算图中保留了梯度缓冲,反向传播时占用的临时显存比前向更大。
解决:验证时用torch.no_grad()包裹;训练循环里每轮结束del loss; torch.cuda.empty_cache()释放临时缓存。num_workers设在4~8即可,不要盲目加满。
6. 让HED真正落地可用的三个技巧:NMS细化、多尺度测试和评估指标
推理阶段的细化处理对最终效果影响最大。HED输出的是概率图,直接用固定阈值二值化,边缘至少粗三到五个像素。我强烈建议加一个边缘NMS:沿梯度方向做局部非极大值抑制,只在梯度方向最大值处保留响应。PyTorch里可以这样实现:
def edge_nms(score_map, grad_threshold=0.1): # score_map: 训练好的HED输出的边缘概率图,(H,W) # 用Sobel算子计算梯度方向 dx = F.conv2d(score_map.unsqueeze(0).unsqueeze(0), torch.tensor([[[[1,0,-1],[2,0,-2],[1,0,-1]]]], dtype=torch.float32).cuda(), padding=1) dy = F.conv2d(score_map.unsqueeze(0).unsqueeze(0), torch.tensor([[[[1,2,1],[0,0,0],[-1,-2,-1]]]], dtype=torch.float32).cuda(), padding=1) mag = torch.sqrt(dx**2 + dy**2) # 沿梯度方向前后各取一点,若当前点不是最大值则抑制 nms = (score_map > score_map_shifted_left) & (score_map > score_map_shifted_right) return score_map * nms.float()这里的核心是把NMS当作一个固定卷积操作处理,GPU上几毫秒就能跑完。写的时候注意梯度方向两个相邻点的采样方式,实际工程中都用直线插值代替八方向离散逼近,效果差异很小,但代码简单很多。
多尺度测试的做法是把输入图分别缩放到0.5、1.0、1.5倍送入模型推理,得到三组概率图后resize回原尺寸取平均。这个技巧在BSDS500评测中能稳定提高ODS约1~2个点,代价是推理时间变三倍。如果不追求榜单精度,单尺度加NMS已经够实际业务用。
评估指标只用两个:ODS(整张测试集上固定阈值的最大F1)和OIS(每张图各自选最优阈值后的平均F1)。这两个指标直接反映“边界定位精度”和“整体召回”的平衡,比单独看准确率或召回率有意义。ODS差说明你的阈值选择不稳定或模型输出置信度偏低;OIS差说明模型在图与图之间的表现波动大。训练中每隔几个epoch在验证集上算一次ODS/OIS,比盯着loss图判断是否收敛靠谱得多。
我个人验证HED效果的习惯是:测试集里挑十张最难的图像,把Canny、Prewitt和HED的预测并排打印成一张大图观察,而不是只看ODS数字。数字只能告诉你整体水平,但边缘检测的用户体验在于细线的完整度和误检率——这两点恰恰是HED相对传统算子的核心优势。做部署时,再额外检查一遍边缘概率图在低对比度区域的响应是否稳定,这个位置最容易在FPGA量化后出问题。希望这个方向的经验能帮你少走一轮弯路,把HED真正落到自己的项目里。
本文还有配套的精品资源,点击获取