简介:HED是一种基于深度学习的边缘检测方法,通过融合卷积神经网络多层特征,捕获从粗略到精细的边缘信息,在复杂场景下明显优于Canny、Sobel等传统算子。这套轻量级代码包面向计算机视觉开发者和研究人员,提供了一套完整的HED算法部署与测试基础环境。压缩包共3个文件,包含一个Python主脚本、一个Caffe部署配置文件和一个预训练模型下载脚本,整体仅2KB,结构精简,方便快速了解HED模型的调用流程和多分支输出结构。目前已有1239人学习下载。借助这套代码,用户可以快速搭建HED边缘检测模型,在BSDS500等数据集上验证多尺度边缘提取效果,也可基于代码学习侧输出分支加权融合的实现思路,为进一步微调、改进或迁移到其他深度学习框架提供参考起点。
1. 为什么边缘检测绕不开 HED:从 Canny 到整图嵌套学习的跃迁
做轮廓提取、文档矫正或医学切片分割时,十个人里有九个最初用的都是 Canny:sobel 算梯度、非极大值抑制、双阈值连接边缘。Canny 对强对比、低噪声图像表现不错,可一旦遇到弱边缘、纹理背景或遮挡边界,输出的边缘碎成一片,拿来当下游分割或配准的先验,需要大量二次清理。HED(Holistically-Nested Edge Detection)真正改变的不是某个算子,而是把边缘检测变成了端到端的深度学习图像生成任务:用 VGG16 的五个卷积阶段分别输出多尺度边缘概率图,再学一个融合层把粗细不一的响应合并成一张整图边缘。在 BSDS500 基准上,HED 的 ODS 从 Canny 的 0.6 上下直接抬到 0.78 以上,这个差距不是换一个手工滤波器能追上的。这篇博文按“原理—推理复现—训练调参—后处理”四段展开,适合要做轮廓提取、分割预处理或可控二值边缘掩码的算法工程师,照着命令能跑出第一张 HED 边缘图,也调得出自己的版本。
2. HED 的骨架:多尺度 side 输出与深度监督的配合逻辑
2.1 从 VGG16 拆出五个 stage:边缘特征从细节到语义的逐层抽象
HED 没有重新设计骨干网络,而是直接复用 VGG16 的卷积部分作为特征提取器。常见做法是把torchvision.models.vgg16(pretrained=True)的features按池化层切成五段:conv1覆盖第一次池化前的两层卷积,conv2覆盖第二次池化前,依此类推。这样每一段输出对应一个空间分辨率的特征图,从最细的像素纹理到最粗的语义轮廓全都保留。
这里的关键在于“每段都要监督”,而不是只监督最后一层。边缘任务和分类任务不同,边缘既要求精确定位(靠浅层细粒度特征),又要求语义完整(靠深层感受野)。如果只取最后的conv5,边缘会粗得失去定位精度;只取conv1,则全是灰度跳变和纹理噪声。因此 HED 在每个 stage 后面接一个 1×1 卷积,把特征图压缩成单通道概率响应,这一路的输出被称为 side output:
class HED(nn.Module): def __init__(self): super().__init__() vgg = models.vgg16(pretrained=True).features self.conv1 = vgg[0:5] # 第 1 个池化前 self.conv2 = vgg[5:10] # 第 2 个池化前 self.conv3 = vgg[10:17] # 第 3 个池化前 self.conv4 = vgg[17:24] # 第 4 个池化前 self.conv5 = vgg[24:31] # 第 5 个池化前 self.side1 = self._side_layer(64) self.side2 = self._side_layer(128) self.side3 = self._side_layer(256) self.side4 = self._side_layer(512) self.side5 = self._side_layer(512) # 融合层把所有 side 概率图 concat 后压成 1 通道 self.fuse = nn.Conv2d(5, 1, kernel_size=1, padding=0) def _side_layer(self, in_channels): return nn.Sequential( nn.Conv2d(in_channels, 1, kernel_size=1, padding=0) )实际论文里的 side 分支还会在 1×1 卷积前后补一组卷积核稍大的层来增加感受野,但上面这段代码已经能表达核心结构。五个 side 分支分别输出1通道的响应图,而fuse层接收的是五个分支拼起来的结果,输出最终融合边缘图。网络层数在这里体现的作用,比单纯加深更值得注意:HED 的“深”是纵向多阶段、横向多尺度的组合,不是把卷积层数无脑加到两百层。
2.2 深度监督:每个尺度都喂一份边缘标注
HED 与普通编码器—解码器模型最大的不同是它对五个 side 输出都施加监督。训练时的总损失一般写成:
$$ L = \sum_{i=1}^{5} w_i \cdot L_{side}^{(i)} + w_{fuse} \cdot L_{fuse} $$
其中L_side是某个尺度上的边缘概率图与真值之间的损失,L_fuse是融合层最终输出的损失。实际操作中很多复现直接取权重均为 1,因为各 side 分支已经承担了不同语义层的学习,权重差一个量级以上才会明显改变训练方向。
每个 side 的损失不能简单地用交叉熵,因为边缘像素在整张图像里占比通常不到 10%,正负样本严重失衡。HED 采用类平衡交叉熵,用边缘像素占比 β 来约束两类损失:
$$ L_{side} = -\beta \sum_{j \in Y_+} \log P(y_j=1) - (1-\beta) \sum_{j \in Y_-} \log (1 - P(y_j=1)) $$
其中β = 边缘像素数 / 总像素数。这样做的好处是浅层 side1、side2 不会因为边缘像素太少而直接退化成“全输出零”的平凡解。这个思想在此后的很多深度学习损失函数里都能看到,它解决的本质问题不是精度,而是模型在稀疏目标下的收敛稳定性。
2.3 为什么叫 Holistically-Nested:融合层的嵌套语义
“Holistic”指的是 HED 对整幅图像做预测,而不是像早期 Sliding Window 边缘检测那样逐块分类;“Nested”指的是监督信号嵌套在五层不同深度的特征上。融合层接收五个 side 的预测图,通过 1×1 卷积学习一套线性组合权重。理想情况下,浅层支路提供像素级定位,深层支路排除纹理噪声、保留物体整体轮廓,融合层则负责判断当前图像“到底该更信哪个尺度”。
| side 输出 | 对应 VGG 阶段 | 输出分辨率(相对输入) | 表达倾向 |
|---|---|---|---|
| side1 | conv1 | 1/2 | 细纹理、局部灰度跳变 |
| side2 | conv2 | 1/4 | 短线段、角点结构 |
| side3 | conv3 | 1/8 | 中等长度轮廓、边缘分组 |
| side4 | conv4 | 1/16 | 物体部件边缘 |
| side5 | conv5 | 1/32 | 完整目标轮廓、语义边界 |
这里有个容易被忽略的细节:五个 side 的分辨率不同,训练时通常把每个 side 图双线性上采样回原图尺寸再算损失。推理时同样要上采样回原图,才能和输入图像对齐。你如果自己实现 HED,必须注意上采样倍率要和池化次数成 2 的幂次关系,否则边缘位置会出现半个像素级的偏移,这类偏差在细小血管或裂缝检测中会直接导致评测指标下降 1 到 2 个点。
3. 用预训练 HED 跑通第一批边缘图:深度学习环境配置与最小推理代码
3.1 先解决环境与权重文件:千万别随手找权重
跑 HED 推理不需要 GPU 也能完成,但 500×500 以上的图像在 CPU 上做一次前向大约要 2 到 5 秒,所以有条件还是配一下深度学习环境。常见的配置是 Python 3.10、PyTorch 2.x、torchvision 1.1 以上、OpenCV 用于图像读写。CUDA 版本建议直接用 PyTorch 官方默认的发布版,省去手动配 cuDNN 的麻烦。
预训练权重方面最稳妥的路线是找 PyTorch 复现仓库里提供的hed.pth或bwnet.pth(部分实现把 HED 和 RCF 一起打包)。拿到的权重要先做两个检查:第一看state_dict的 key 是否和你的模型类命名一致;第二看权重里是否包含 BN 层的 moving_mean 和 moving_var。HED 官方最初基于 Caffe 发布,很多转换权重只保留了卷积核参数,没有 BN 统计量。遇到这种情况,要么换一个实现,要么手动把 BN 层降级成恒等映射,否则推理时直接使用默认 BN 参数会导致整张图输出灰蒙蒙,边缘响应全部偏小。
3.2 推理主流程:预处理、前向、上采样三件事
下面这段代码是一个可运行的完整推理脚本。它要做三件事:把任意尺寸输入处理成 448×448(不足部分用均值填充)、过一遍前面定义的 HED 网络、把输出上采样回原尺寸。这里我选用等比例缩放到长边 448 再做边界填充的方式,可以避免图像被直接拉伸变形。
import cv2 import numpy as np import torch from torchvision import transforms # 假设前面已定义 HED 类 model = HED() state = torch.load("hed.pth", map_location="cpu") model.load_state_dict(state, strict=False) model.eval() img = cv2.imread("demo.jpg") img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) h, w = img_rgb.shape[:2] # 等比缩放 + 填充到 448x448 scale = 448 / max(h, w) new_h, new_w = int(round(h * scale)), int(round(w * scale)) resized = cv2.resize(img_rgb, (new_w, new_h)) canvas = np.full((448, 448, 3), 128, dtype=np.uint8) offset_y, offset_x = (448 - new_h) // 2, (448 - new_w) // 2 canvas[offset_y:offset_y + new_h, offset_x:offset_x + new_w] = resized tensor = transforms.ToTensor()(canvas).unsqueeze(0) tensor = transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])(tensor) with torch.no_grad(): outputs = model(tensor) # 返回 (sides, fuse) sides, fuse = outputs fuse_prob = torch.sigmoid(fuse).squeeze().numpy() fuse_prob = (fuse_prob * 255).astype(np.uint8) # 去掉填充区域再缩回原尺寸 fuse_valid = fuse_prob[offset_y:offset_y + new_h, offset_x:offset_x + new_w] edge_map = cv2.resize(fuse_valid, (w, h), interpolation=cv2.INTER_LINEAR)这段代码有两个地方值得解释。前向时模型返回的sides是五个侧输出的列表,fuse是融合层输出;融合层输出的是 logits,要过sigmoid才能当作概率图。预处理里用Normalize用的是 ImageNet 的均值和方差,原因是 VGG16 预训练权重就是在这个分布上训练的,如果你跳过这一步,边缘图会带着明显的高频噪声。最后把预测图裁剪回原图大小时,INTER_LINEAR已经足够,用INTER_CUBIC反而会在边缘周边产生振铃。
3.3 side 输出怎么组合:不是永远要用 fuse
如果你打开sides分别可视化,会看到 side1 几乎在描摹纹理,side3 开始出现物体的主轮廓,side5 只剩下大块前景物体的外边界。绝大多数情况下直接使用融合输出即可,但有三种场景我会改用 single side 或自定义组合:
- 对细小物体(裂缝、电线)做检测,side1 和 side2 的定位更准;
- 对大面积遮挡判断,side5 更有语义完整性;
- 目标边缘粗细不均匀时,把 side3 和 side4 按 0.4 和 0.6 加权,比直接用 fuse 更能保留细节。
可以这样取融合结果:
edge_custom = 0.4 * sides[2] + 0.6 * sides[3] edge_custom = torch.sigmoid(edge_custom).squeeze().numpy()得到的概率图是浮点数,后续做非极大值抑制时比 fuse 的离散化输出更稳定。这是我在实测了几个旧版权重后总结出的规律:官方权重里的 fuse 层是为 BSDS 这种中等尺寸物体设计的,当你换到自己的数据分布时,直接重新学习融合权重,往往比改损失函数更快见效。
4. 训练与调参:HED 微调自己的数据时,我该动哪些参数
4.1 数据准备与增强:边缘真值是个概率分布
HED 训练最经典的数据集是 BSDS500,包含 200 张训练图、100 张验证图和 200 张测试图。每张图有多个人工标注的边缘结果,有些人标注的是物体轮廓,有些人连阴影过渡也画进去。训练时不能直接拿某一人的标注当天真值,常见做法是把多份标注集合成边缘概率——某个像素被 8 个人中 5 个人标为边缘,它的标注值就是 0.625。这样得到的是一个介于 0 和 1 之间的软真值,损失函数计算时按软真值计算。
数据增强环节我会保守一点,只做随机翻转、旋转 90 度的倍数和 0.8~1.2 倍的随机缩放。不要在边缘检测任务上用随机裁剪后直接送进去,因为裁剪会破坏边缘与整图上下文的相对关系,HED 恰恰依赖上下文。如果显存不够,固定裁剪到 320×320 也行,但要把裁剪区域中心附近的边缘密度作为采样权重,提高采样到强边缘区域的比例,否则网络会花大量迭代去学习平坦背景。
典型的训练配置如下表:
| 参数 | 取值 | 备注 |
|---|---|---|
| 输入尺寸 | 448×448 or 320×320 | 长边等比拉伸 + 填充 |
| 学习率 | 1e-4(微调) | 从头训练用 1e-3,但很慢 |
| 动量 | 0.9 | 保持 VGG 历史更新 |
| 权重衰减 | 2e-4 | 抑制过拟合 |
| batch size | 4~8 | 取决于显存 |
| epoch | 20~40 | BSDS 小,不用跑太久 |
epoch的选择要看训练曲线是否出现 side1 的损失降不动的情况。side1 是浅层输出,训练后期容易过拟合于边缘噪声,我会在损失不再下降时把学习率衰减到 1e-5,再跑 5 个 epoch 收尾。
4.2 损失函数实现:类平衡交叉熵的写法
如果把 HED 的损失函数写成 PyTorch,可以这样实现,同时保留融合损失项:
def balanced_cross_entropy(pred, target): # pred: [B, 1, H, W] target: [B, 1, H, W] eps = 1e-6 pred = torch.clamp(pred, eps, 1 - eps) beta = target.mean().clamp(eps, 1 - eps) loss_pos = -beta * (target * torch.log(pred)).mean() loss_neg = -(1 - beta) * ((1 - target) * torch.log(1 - pred)).mean() return loss_pos + loss_neg side_loss = sum(balanced_cross_entropy(s, target_up) for s in sides) fuse_loss = balanced_cross_entropy(fuse, target_up) loss = side_loss + fuse_loss注意这里beta不是全局统计值,而是每个 batch 内部 target 的边缘占比,这样在 batch 之间边缘密度差异大时依然能自适应。一个容易踩的坑是:直接把target上采样的二值图当作软真值参与torch.log,如果 target 为 1 而 pred 被 sigmoid 后压缩到 0.0 附近,梯度会爆炸。所以要先用clamp把 pred 限制在 1e-6 到 1 - 1e-6 之间。
4.3 训练中最容易翻车的三个细节
第一个是 BN 层的行为。VGG16 原始结构里是没有 BN 的,很多复现版本为了稳定训练额外加了 BN。推理时 BN 必须走 cumulative 统计量,而不是跑 batch 统计量。你在迁移到 PyTorch 2.x 后,要确认model.training = False时 BN 确实使用的是running_mean/running_var,否则一张图翻来覆去测试会得到完全不同的输出。
第二个是冻结策略。常见的做法是前三层 stage 冻结,stage4 和 stage5 跟着边缘数据微调,side 分支永远训练。如果数据量和你的领域差异很大,例如从自然图像换到遥感影像,我建议第一轮把 stage3 到 stage5 解冻,学习率用 1e-5,让底层少动,上层多动。
第三个是评价指标误导。训练时盯着 ODS 看是合理的,但 ODS 是在验证集上做全局最优阈值得到的分数。你会发现随着训练进行,ODS 会先升后降,而降的时候往往不是模型退化了,而是融合层开始和 side 层互相抢梯度。此时可以让 side 分支只参与前 60% 的训练,之后把 side 分支权重 freeze 住,只训融合层,这能显著提升测试集上的泛化效果。
5. 后处理技巧:NMS 融合输出时的阈值自适应与边缘修剪
拿到 HED 输出的概率图后,直接prob > 0.5二值化是大忌。边缘概率图不是均匀分布,强边缘概率接近 1,弱边缘可能只有 0.3,全局固定阈值会在细节多的图像上丢失大量薄边缘。有效做法是先用非极大值抑制(NMS)消除重复响应,再做自适应阈值。
NMS 在这里的操作对象是边缘强度图,思路与 Canny 中沿梯度方向抑制类似,但 HED 输出没有梯度方向。常见做法是用固定窗口的最大值滤波:
from scipy.ndimage import maximum_filter def nms(prob, win_size=5): max_map = maximum_filter(prob, size=win_size, mode="constant") keep = (prob >= max_map).astype(np.float32) # prob >= max_map 在最大值区域会保留一个粗带,进一步只保留中心点 return prob * keep edge_nms = nms(edge_map)这段代码用maximum_filter寻找局部极大值,凡是等于窗口内最大值的像素都保留下来,其余置零。它的问题在于物体轮廓很宽时,NMS 输出也会带一条细线带,所以第二步要对 NMS 结果做形态学细化,用 OpenCV 的cv2.ximgproc.thinning即可。
阈值方面,我推荐一个在验证集上求 F-measure 的自适应方法:取 0.1 到 0.9 按步长 0.05 尝试,对每一档阈值计算精确率和召回率,并选择 F 值最大的一档作为全局阈值:
precisions, recalls = [], [] for t in np.arange(0.1, 0.9, 0.05): pred_bin = (edge_nms > t).astype(np.uint8) tp = np.sum((pred_bin == 1) & (gt > 0.5)) fp = np.sum((pred_bin == 1) & (gt <= 0.5)) fn = np.sum((pred_bin == 0) & (gt > 0.5)) prec = tp / (tp + fp + 1e-6) rec = tp / (tp + fn + 1e-6) precisions.append(prec); recalls.append(rec) best_idx = np.argmax([2 * p * r / (p + r + 1e-6) for p, r in zip(precisions, recalls)]) best_thresh = np.arange(0.1, 0.9, 0.05)[best_idx]最后还有一个容易忽略的“边缘修剪”步骤:去掉长度小于某个阈值的连通域,比如 15 个像素以内的短线。这些短线基本来自纹理噪声,删除后edge_nms更加干净。如果做的是分割预处理,可以用膨胀操作把细小断裂的边缘连接起来;如果做的是配准特征提取,就不要膨胀,保留原始定位精度。
把 NMS、自适应阈值、短边缘过滤三步串起来,再加上多尺度 side 融合,这套后处理流程能让 HED 在非 BSDS 类图像上的可用性明显上一个台阶。先在自己的验证集上把阈值定下来,再固化到推理脚本里,整个边缘检测管线才算真正落地。
本文还有配套的精品资源,点击获取