
边缘检测是计算机视觉里最基础也最容易被低估的一个环节。你在做目标检测、语义分割、图像配准或者三维重建时第一步往往就是把边缘找出来。早年大家一提到边缘检测脑子里第一个蹦出来的就是Canny算子那套“高斯滤波梯度计算非极大值抑制双阈值”的流程稳扎稳打跑了三十年。但这几年深度学习起来之后边缘检测又有了完全不同的做法——不再刻意去设计梯度算子和阈值而是让卷积神经网络自己从数据里学出“什么叫边缘”。这篇文章我准备把两条技术路线放在一起拆开讲清楚Canny为什么是经典深度学习又在改什么以及怎么用PyTorch把两者都亲手实现一遍。这篇文章适合三类人刚入门计算机视觉、想搞明白边缘检测原理的初学者已经会用OpenCV调Canny、但没试过从零实现一遍的进阶玩家想用深度学习方法做边缘检测、但是被HED这类论文的工程细节劝退的实践者。我会把Canny的每一步数学原理、PyTorch代码、深度学习网络的设计思路、训练时踩过的坑一次说清楚你可以照着代码直接复现。1. 为什么从Canny讲到深度学习先聊一个很多人会问的问题Canny已经很好用了为什么还要用深度学习做边缘检测这个问题的答案不在“谁更好”而在两套方法对“边缘”这件事的定义方式完全不同。1.1 Canny凭什么能活三十年Canny算法是John Canny在1986年提出的核心思路非常清晰先对图像做平滑去噪再计算梯度幅值和方向接着做非极大值抑制把边缘“细化”到单像素最后用双阈值和滞后连接把弱边缘筛出来。这一套流程从信号处理的角度定义了一个“最优边缘检测器”在数学上有明确的推导依据。Canny能活这么久一个重要原因是它的可解释性和可控性。你有三个关键参数可以调高斯滤波的sigma控制平滑程度高阈值和低阈值控制边缘的敏感度。在光照均匀、噪声较低的工业质检场景中Canny至今依然是最可靠的选择。就算在深度学习模型已经非常成熟的今天很多人依然会在深度网络的输出上再接一层Canny做后处理。但Canny有几个硬伤也是推动深度学习进场的关键。第一它的梯度计算基于局部邻域本质上是“像素级特征”对噪声和光照变化极其敏感。第二高斯滤波的sigma一上去细小的边缘就被磨掉了细节和抗噪始终是一对矛盾。第三它只能用底层特征无法建模“物体轮廓”这种语义级别的边缘——比如一头牛的轮廓边缘和牛身上的纹理边缘在Canny看来都是边缘但语义上完全不是一个层面的东西。1.2 深度学习方法到底改了什么深度学习方法把边缘检测从“手工设计滤波器”变成了“数据驱动学习”。你不再去推导梯度算子而是用卷积神经网络从大量标注好的边缘数据中自己学习特征表达。这里面最关键的一个转变是边缘检测被重新定义为一个像素级的二分类问题——每个像素要么是边缘要么不是边缘。网络输入一张RGB图像输出一张与输入同尺寸的边缘概率图。训练时用标注好的边缘图做监督信号让网络输出逼近人类标注的边缘位置。所以深度学习的优势也很明显它可以融合不同层次的特征。浅层卷积核学到的边缘是锐利的、细节丰富的但包含大量纹理噪声深层卷积核学到的特征更语义化能分辨出物体的大致轮廓但空间分辨率很低。把多尺度特征融合起来就能同时获得“细节清晰”和“语义准确”的边缘图这是Canny完全做不到的。1.3 为什么选PyTorch来做这件事选PyTorch做实现坦白讲没有太高深的理由。一个是它的自动求导机制让训练网络不需要手推反向传播另一个是它对研究者极度友好模型的搭建、调试、可视化都容易上手。尤其做边缘检测这种需要对每一层特征做可视化的任务PyTorch的动态图机制可以让你在forward过程里随时把中间变量打印出来或者存成图排查问题效率非常高。另外一个现实原因是生态。不管是HED、RCF还是UDEN这类边缘检测模型网上社区的PyTorch实现都比其他框架要多得多你训练的时遇到问题能搜到的解决方案也更多。不管你是刚入门还是想跑论文里的SOTA模型PyTorch都是成本最低的选择。2. Canny算法的完整拆解与PyTorch实现2.1 算法流程从一张灰度图到边缘图Canny处理一张图像的标准流程是五个步骤缺一不可灰度化、高斯滤波、梯度计算、非极大值抑制、双阈值与滞后连接。很多人在OpenCV里一行cv2.Canny()就完事了但我强烈建议至少手动实现一遍因为你只有在手写过程中才会真正理解每个参数的意义。先看步骤之间为什么是这么排序的。灰度化是为了把三通道RGB压缩成单通道亮度信息梯度计算只关心亮度变化颜色信息在这里没什么用。高斯滤波放在灰度化之后、梯度计算之前目的是抑制噪声——如果先算梯度再做平滑噪声会被梯度的放大效果搞得更明显很难处理。梯度计算是核心环节它同时给出边缘的强度幅值和方向后续的非极大值抑制完全依赖方向信息把边缘细化到单像素。双阈值则在最后负责“捡回”那些被噪声压弱的真实边缘。2.2 用卷积实现高斯滤波和梯度计算Canny里的高斯滤波本质上就是一次二维卷积。高斯核的数学形式是G(x, y) (1 / (2 * pi * sigma^2)) * exp(-(x^2 y^2) / (2 * sigma^2))你可以用PyTorch的卷积层来实现不需要自己手写循环。import torch import torch.nn.functional as F import math def gaussian_kernel(size5, sigma1.0): coords torch.arange(size, dtypetorch.float32) - size // 2 x, y torch.meshgrid(coords, coords, indexingij) kernel torch.exp(-(x**2 y**2) / (2 * sigma**2)) kernel kernel / kernel.sum() return kernel.reshape(1, 1, size, size) def apply_gaussian(img_tensor, size5, sigma1.0): # img_tensor: [1, 1, H, W] kernel gaussian_kernel(size, sigma) return F.conv2d(img_tensor, kernel, paddingsize // 2)gradient计算用的是Sobel算子。Sobel本质上就是在x方向做差分、在y方向做平滑或者反过来对应两个3x3卷积核Gx [[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]] Gy [[-1, -2, -1], [ 0, 0, 0], [ 1, 2, 1]]注意Sobel核的数值设计中心权重是2是为了给中心像素更高权重同时抑制对角线方向噪声。梯度幅值用sqrt(Gx^2 Gy^2)计算梯度方向用arctan2(Gy, Gx)得到取值范围是[-pi, pi]。2.3 非极大值抑制的向量化实现非极大值抑制NMS是Canny里最容易写错的一步。它的目标是在梯度方向上看当前像素是不是局部最大值如果不是就把这个像素抑制掉置为0最终得到细化的单像素边缘。为什么需要这一步因为梯度幅值图上的边缘不是一条细线而是“一条很宽的亮带”——梯度在真实边缘附近会有一段连续的高响应区域。NMS就是把这个亮带压成一条线。具体操作是把每个像素的梯度方向量化为四个方向——水平、垂直、45度对角线、135度对角线。然后沿梯度方向比较当前像素和相邻两个像素的幅值如果当前像素是最大的就保留否则抑制。在纯Python里写for循环会非常慢但用PyTorch可以用torch.where向量化实现def non_max_suppression(grad_mag, grad_angle): H, W grad_mag.shape grad_angle grad_angle * 180.0 / math.pi grad_angle[grad_angle 0] 180.0 # 初始化输出 nms torch.zeros_like(grad_mag) # 方向量化 direction torch.zeros_like(grad_angle, dtypetorch.long) direction[(grad_angle 22.5) | (grad_angle 157.5)] 0 # 水平 direction[(grad_angle 22.5) (grad_angle 67.5)] 45 # 45度 direction[(grad_angle 67.5) (grad_angle 112.5)] 90 # 垂直 direction[(grad_angle 112.5) (grad_angle 157.5)] 135 # 按方向比较相邻像素 # 水平方向左右像素 left torch.zeros_like(grad_mag) right torch.zeros_like(grad_mag) left[:, 1:] grad_mag[:, :-1] right[:, :-1] grad_mag[:, 1:] # 垂直方向上下像素 up torch.zeros_like(grad_mag) down torch.zeros_like(grad_mag) up[1:, :] grad_mag[:-1, :] down[:-1, :] grad_mag[1:, :] # 45度方向 diag45_1 torch.zeros_like(grad_mag) diag45_2 torch.zeros_like(grad_mag) diag45_1[1:, :-1] grad_mag[:-1, 1:] diag45_2[:-1, 1:] grad_mag[1:, :-1] # 135度方向 diag135_1 torch.zeros_like(grad_mag) diag135_2 torch.zeros_like(grad_mag) diag135_1[:-1, :-1] grad_mag[1:, 1:] diag135_2[1:, 1:] grad_mag[:-1, :-1] # 对每个方向判断是否局部最大 cond_h (grad_mag left) (grad_mag right) cond_v (grad_mag up) (grad_mag down) cond_45 (grad_mag diag45_1) (grad_mag diag45_2) cond_135 (grad_mag diag135_1) (grad_mag diag135_2) nms torch.where(direction 0, cond_h.float(), nms) nms torch.where(direction 45, cond_45.float(), nms) nms torch.where(direction 90, cond_v.float(), nms) nms torch.where(direction 135, cond_135.float(), nms) return nms * grad_mag # 保留局部最大值的原始幅值我踩过的坑是边界像素的处理。上面的实现用torch.zeros给图像边界补了0但这会在边界处引入一条“假边缘”。一个简单粗暴的解决办法是把边界的像素直接置为0或者干脆在计算之前对图像做padding算完之后crop掉。2.4 双阈值与滞后连接Canny的“决策层”NMS之后图像里剩下的像素都是局部极大值但仍然有大量是噪声引起的弱响应。双阈值要做的事是设置一个高阈值high和一个低阈值low把像素分成三类——大于high的是强边缘肯定保留介于low和high之间的是弱边缘只有与强边缘相连时才保留小于low的直接丢弃。为什么弱边缘要跟强边缘相连才保留这是Canny算法一个非常朴素的假设真实的边缘往往是连续的如果一个弱的像素和强边缘相邻那它大概率是同一条边缘的延续如果它孤零零地出现在噪声区域那大概率是噪声。双阈值设置的经验值是高低阈值比例在2:1到3:1之间比如low50, high150。比例太大容易漏检太小又无法过滤噪声。滞后连接在OpenCV里是内置函数PyTorch实现可以用连通域标记或循环扫描但在深度学习时代大部分实现用的是简单规则很多工程场景甚至直接用强边缘加上“弱边缘且邻域有强边缘”来判断def double_threshold(nms, low50, high150): strong (nms high).float() weak ((nms low) (nms high)).float() # 简化版滞后连接检查弱边缘像素的8邻域是否有强边缘 # 用padding max_pool模拟8邻域取最大值 strong_padded F.pad(strong.unsqueeze(0).unsqueeze(0), (1, 1, 1, 1), modeconstant, value0) neighbor_strong F.max_pool2d(strong_padded, kernel_size3, stride1, padding0).squeeze() edge strong weak * neighbor_strong return (edge 0).float()严格来说真正的滞后连接需要迭代传播——弱边缘连接后变成边缘再去连接别的弱边缘。但实际工程中用一次邻域判断就够了效果已经很不错。如果你想做精确的迭代版本需要用到并查集Union-Find做连通域标记在那个基础上做多轮上级传播。2.5 完整代码与可视化把上面的步骤串起来再补一点辅助函数。def canny_edge_detection(img_tensor, sigma1.0, low50, high150): # img_tensor: [1, 1, H, W], 值范围 [0, 1] smoothed apply_gaussian(img_tensor, size5, sigmasigma) sobel_x torch.tensor([[[[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]]]], dtypetorch.float32) sobel_y torch.tensor([[[[-1, -2, -1], [0, 0, 0], [1, 2, 1]]]], dtypetorch.float32) gx F.conv2d(smoothed, sobel_x, padding1) gy F.conv2d(smoothed, sobel_y, padding1) grad_mag torch.sqrt(gx**2 gy**2).squeeze() grad_angle torch.atan2(gy.squeeze(), gx.squeeze()) nms non_max_suppression(grad_mag, grad_angle) edge double_threshold(nms, low, high) return edge可视化时注意梯度幅值的动态范围在不同图像间差异很大直接用matplotlib的imshow会显得很暗。建议先做一次线性拉伸或取log把幅值分布拉开再看。2.6 参数调优心得Canny的参数调整我总结下来有几个实用经验。第一sigma的选择直接影响边缘的尺度。sigma小边缘细碎、噪声多sigma大边缘粗、细节丢失。对常规自然图像sigma取1.0到1.5比较平衡如果图像本身已经被压缩过或者噪声含量高建议先小幅提升sigma而不是直接压低阈值因为压低阈值会把噪声也带进来。第二高低阈值的设定不一定从固定值开始。我的习惯是先跑一个高阈值观察强边缘的数量然后固定高阈值从高阈值的一半开始往上调低阈值直到弱边缘噪声明显增加为止。整个过程比直接凭经验拍两个数要靠谱得多。第三如果是在深度学习pipeline里用Canny记得固定好随机种子和参数否则同一个预处理流程每次输出的边缘图会有细微差异这种差异在训练时会变成一种隐式噪声。3. 从经典到神经深度学习边缘检测的核心设计思路3.1 边缘检测为什么被建模为像素级二分类深度学习的边缘检测思路跟Canny完全不同。Canny是在像素层面根据梯度响应值做决策深度学习方法则把整个问题建模成一个监督学习任务给网络一张图像输出每个像素属于边缘的概率。这看起来只是一个输出形式的区别但带来的变化是本质性的。二分类建模意味着你需要一个训练数据集——图像加对应的边缘标注图Ground Truth。模型通过最小化损失函数自动学习什么样的图像模式对应边缘。它可以学到纹理边缘、轮廓边缘、遮挡边界甚至暗光条件下的模糊边缘这些靠手工设计梯度算子是很难做到的。但这个建模方式有一个必须面对的难点边缘像素在整张图里往往只占5%到10%和占绝大多数背景像素相比正负样本极度不平衡。如果直接用一个普通的BCE损失模型很容易“偷懒”——把所有像素都预测为背景损失也很低但边缘完全检测不出来。3.2 损失函数设计类别不平衡是最大的坑对付类别不平衡最常用的手段是给正样本边缘像素更大的权重。HED论文里用的就是这个思路做法是先统计一张图里边缘像素占比然后对正样本和负样本设置不同的权重def hed_loss(pred, target, epsilon1.0): # pred: [B, 1, H, W] 网络输出概率 # target: [B, 1, H, W] 二值边缘标注 pos_weight (target.sum(dim[2, 3], keepdimTrue) epsilon) / (target.numel() / target.shape[0]) neg_weight 1.0 - pos_weight bce F.binary_cross_entropy(pred, target, reductionnone) weighted (pos_weight * target neg_weight * (1 - target)) * bce return weighted.mean()这个损失的核心就是边缘像素少那就给每个边缘像素更大倍的损失贡献逼着模型不敢忽视它们。epsilon是一个平滑项防止某张图里完全没有边缘标注时出现除零错误。顺便多说一句如果你的边缘标注不是二值图而是一个0到1之间的软标签比如融合了多个标注者的标注那直接用BCE配权重依然可以工作。如果标签里边缘是有宽度的建议在训练前做一次形态学细化或者把网络的输出跟着做一次高斯模糊再算损失不然模型会学出“粗边”影响最终精度。3.3 多尺度特征融合让网络同时看细节和结构深度边缘检测模型从整体思路上看都不会偏离一个框架骨干网络提取多尺度特征侧面对每个尺度做边缘概率预测最后把每个尺度的预测融合输出训练时对每个侧输出做深度监督。为什么要做多尺度回到Canny的痛点——Canny只有单一尺度高斯滤波的sigma决定了它看的是细节还是轮廓鱼与熊掌不可兼得。卷积神经网络天然有这种能力浅层特征图分辨率高、感受野小能捕捉像素级锐利边缘深层特征图分辨率低、感受野大能捕捉物体的语义轮廓。如果只用一个尺度的特征做预测要么边缘细节足够但是噪声爆炸要么轮廓很准但细节全丢。用特征金字塔的直觉来理解更舒服你的网络就像一名侦察兵浅层是“贴脸观察”——能看到每一根小草的运动但也容易把风中的灰尘误判为目标深层是“山顶俯瞰”——能看到一片森林的走势但看不清每一棵树。真正的边缘检测需要一个既能看到树木又能看到森林的东西多尺度融合就是这个“上帝视角”。3.4 评估指标ODS、OIS、AP都表示什么评估边缘检测模型常用的三个指标是ODS、OIS和AP。很多人搞不清这三个指标的区别我在这里一次性说清楚。ODSOptimal Dataset Scale是指在整个数据集上找一个统一的阈值用这个阈值对模型输出的概率图做二值化然后计算F1分数。OISOptimal Image Scale则是对每张图找各自的最优阈值再计算F1再取平均。显然OIS通常会比ODS略高一点因为它对每张图做了自适应调整。APAverage Precision是基于精确率-召回率曲线计算的平均精度不需要二值化直接按照预测概率排序反映的是模型在不同阈值下的综合表现。在BSDS500数据集上Canny的ODS大约在0.6左右传统HED可以到0.75以上现在更新的模型能做到0.8以上。这三个数字放在一起看你就能直观感受到深度学习方法带来的提升有多大。很多做工业应用的工程师不太理解AP和ODS的意义但我建议至少关注一下ODS因为它在真实产品里约等于“用一个固定阈值上线后的实际表现”更贴近工程。4. 搭建一个简化版HED并完成训练HED是Holistically-Nested Edge Detection的缩写是边缘检测领域里程碑式的深度模型。它的核心思想是用VGG16作为骨干在每个stage的输出后接一个侧输出层每个侧输出都算一个损失最终把所有侧输出融合成一个边缘概率图。完整版HED的代码量比较大这里我实现一个简化版本保留多尺度侧输出和深度监督的精髓但去掉了部分细节让代码更容易跑通和看懂。4.1 数据准备BSDS500处理与DataLoader训练边缘检测模型最经典的数据集是BSDS500。它包含200张训练图、100张验证图和200张测试图每张图有多个标注者给出的边缘标注。这个数据集最特殊的地方是每个人标注的边缘都不一样同一个物体的轮廓在不同的人眼里有细微差别。所以实际使用时会融合多个标注生成一个软边缘图——每个像素的值表示有多少比例的标注者认为它是边缘。训练时再对它做阈值化或者直接作为软标签训练。DataLoader的核心代码如下import os from torch.utils.data import Dataset from PIL import Image import torchvision.transforms.functional as TF class BSDSDataset(Dataset): def __init__(self, image_dir, label_dir, size(256, 256)): self.image_dir image_dir self.label_dir label_dir self.image_files sorted(os.listdir(image_dir)) self.size size def __len__(self): return len(self.image_files) def __getitem__(self, idx): img_path os.path.join(self.image_dir, self.image_files[idx]) label_path os.path.join(self.label_dir, self.image_files[idx].replace(.jpg, .png)) image Image.open(img_path).convert(RGB) label Image.open(label_path).convert(L) image TF.resize(image, self.size) label TF.resize(label, self.size, interpolationImage.NEAREST) img_tensor TF.to_tensor(image) # [0,1] label_tensor (TF.to_tensor(label) 0).float() return img_tensor, label_tensor两个细节值得特别注意标签resize时要用最近邻差值不能用双线性。因为边缘是二值标签双线性差值会在边缘处产生中间值把边缘变模糊影响训练监督信号的准确性。另外如果要对图像做随机翻转、随机裁剪这类数据增强标签必须跟图像做完全相同的变换工程上最简单的方法是写一个函数同时处理两者。4.2 网络结构简化版HED的核心设计我们的模型设计思路骨架用VGG16的前四个stage因为第五stage在边缘检测中贡献较小而且显存消耗大每个stage的最后一个卷积输出作为侧特征再接一个1x1卷积把特征降到单通道边缘概率图训练时四个侧输出各算一个loss测试时把它们融合。import torch.nn as nn class VGGBlock(nn.Module): def __init__(self, in_ch, out_ch, num_convs): super().__init__() layers [] for i in range(num_convs): layers.append(nn.Conv2d(in_ch if i 0 else out_ch, out_ch, 3, padding1)) layers.append(nn.ReLU(inplaceTrue)) layers.append(nn.MaxPool2d(2, 2)) self.block nn.Sequential(*layers) def forward(self, x): return self.block(x) class SimpleHED(nn.Module): def __init__(self): super().__init__() self.stage1 VGGBlock(3, 64, 2) # 输出 1/2 self.stage2 VGGBlock(64, 128, 2) # 输出 1/4 self.stage3 VGGBlock(128, 256, 3) # 输出 1/8 self.stage4 VGGBlock(256, 512, 3) # 输出 1/16 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.fuse nn.Conv2d(4, 1, 1) def forward(self, x): s1 self.stage1(x) s2 self.stage2(s1) s3 self.stage3(s2) s4 self.stage4(s3) p1 self.side1(s1) p2 self.side2(s2) p3 self.side3(s3) p4 self.side4(s4) # 上采样到输入分辨率 p1 F.interpolate(p1, scale_factor2, modebilinear, align_cornersFalse) p2 F.interpolate(p2, scale_factor4, modebilinear, align_cornersFalse) p3 F.interpolate(p3, scale_factor8, modebilinear, align_cornersFalse) p4 F.interpolate(p4, scale_factor16, modebilinear, align_cornersFalse) cat torch.cat([p1, p2, p3, p4], dim1) fused self.fuse(cat) return torch.sigmoid(p1), torch.sigmoid(p2), torch.sigmoid(p3), torch.sigmoid(p4), torch.sigmoid(fused)这个简化版HED跟原版的差别是原版在VGG每个stage的输出后还会再接一个固定数量的卷积层原版的融合层还带一个权重可学的融合系数而且训练时对侧输出做了加权。我这个版本为了可读性砍掉了这些工程细节但多尺度监督和融合的思想是保留的。你把结构跑顺之后再去对照论文改这些细节会容易很多。4.3 训练配置与训练过程实录训练时我有几个习惯性设置。第一骨干部分初始化为预训练VGG16权重。边缘检测数据量不大BSDS500的训练集总共才200张图从零训练很容易过拟合到细节纹理上用预训练权重做初始化能明显提升收敛速度和最终精度。第二学习率要比常规分类任务低我习惯用1e-5量级因为边缘检测对低层特征的扰动极其敏感。model SimpleHED() optimizer torch.optim.Adam(model.parameters(), lr1e-5) scheduler torch.optim.lr_scheduler.StepLR(optimizer, step_size30, gamma0.5) for epoch in range(50): model.train() total_loss 0 for img, label in train_loader: img, label img.cuda(), label.cuda() p1, p2, p3, p4, fused model(img) loss1 hed_loss(p1, label) loss2 hed_loss(p2, label) loss3 hed_loss(p3, label) loss4 hed_loss(p4, label) loss_fuse hed_loss(fused, label) loss loss1 loss2 loss3 loss4 loss_fuse optimizer.zero_grad() loss.backward() optimizer.step() total_loss loss.item() print(fEpoch {epoch1}: loss {total_loss / len(train_loader):.4f})训练过程中我观察到一个非常典型的现象浅层侧输出stage1、stage2的loss下降得很快但最终预测里有很多纹理噪声深层侧输出stage3、stage4收敛慢但一旦学出来了物体轮廓非常清晰。这正是多尺度监督的意义——每一层都在用自己的方式逼近边缘最终融合时取长补短。4.4 推理与后处理训练完成后推理我们通常只取融合输出fused它是一个形状为[B, 1, H, W]的概率图。直接用阈值0.5二值化会偏保守导致边缘丢失。我的习惯是先用一个较低的阈值比如0.2到0.3保留足够多的候选边缘然后做一个形态学细化去掉多余像素。一个非常实用的小技巧是在输出概率图之后用一次非极大值抑制做后处理把边缘压到单像素宽度。注意这个NMS不是Canny里的那个NMS但作用类似——沿着梯度方向只保留局部最大值。你可以直接复用Canny实现里的NMS逻辑只是输入从梯度幅值换成网络输出的边缘概率。5. 常见问题与排查技巧实录自己做边缘检测的整个过程中我踩过不少坑这里整理出频率最高的几个以及对应的排查方法。问题现象可能原因解决方案Canny结果全是断断续续的碎线高斯sigma太大或低阈值太高降低sigma保持2:1到3:1的高低阈值比例边缘图里大量细密纹理没有做NMS或Canny高阈值太低先检查NMS是否生效再提高高阈值PyTorch实现的学习网络loss不下降标签resize用了双线性标签resize必须用NEAREST检查标签是否都是全黑标注路径错边缘概率图输出一片模糊只用自己的stage输出没有多尺度融合检查上采样层是否对齐深层输出空间分辨率是否太小训练时显存不足输入尺寸过大、batch过大把输入resize到256x256batch降到2-4或者用混合精度训练侧输出浅层边缘太噪浅层特征本身细节过于丰富在融合层中给深层特征更高的权重或在side层增加一层卷积做平滑边缘位置偏移、不贴合物体实际轮廓标签本身标注位置有偏差BSDS500是人工标注天生存在偏差位置可考虑用软标签训练替代硬标签预测边缘粗、不是单像素后处理缺少细化步骤在输出概率图上做一次NMS或形态学细化写代码时还有一个容易出错的地方数据归一化。PyTorch的ToTensor()默认会把像素值缩放到0到1但有些人习惯性除以255的时候再除以一遍导致图像变暗Canny的阈值全都需要重新调。我的习惯是统一在网络里约定输入范围是[0,1]所有预处理和后处理都以此为基准。如果你是在自己制作的数据集上训练标注质量非常关键。我见过不少失败案例是因为标注的人手抖边缘画得歪歪扭扭模型学出来的边缘自然也是歪的。如果条件允许每个样本让多个标注者独立标注然后取平均如果不能多人标注至少要在训练前对标签做一次形态学清理。一个小冷门但是很实用的问题GPU训练时上采样方法的选择。F.interpolate的modebilinear和modenearest结果差异很大。边缘概率图是连续的概率值用bilinear更合理标签图是离散的二值用nearest。我把这个写进代码注释里了但很多人复现的时候还是搞混效果差一大截。6. 我的一点实践体会我自己在项目里用Canny做边缘检测很多年后来转用深度学习方法后最大的感受是经典算法和深度学习不是替代关系而是各管一段。Canny那套“梯度NMS双阈值”的框架放到今天依然是优秀的边缘细化工具深度模型负责的是理解语义、输出一个语义级别的边缘响应但最终的边缘细化很多场景下我反而会用Canny的NMS或双阈值思路去修正。实操上我目前比较推荐的组合方案是用深度模型做主检测器输出边缘概率图用Canny做辅助通道负责保留那些深度模型容易忽略的细碎纹理边缘最后把两者在概率层面融合。这个方案在真实项目里比单用任何一个都稳健。如果你打算把边缘检测模块嵌到自己的项目里给你一个明确的顺序建议先用OpenCV的Canny快速跑通pipeline确认边缘检测的输入输出格式和整体流程如果Canny的精度不够再切到深度学习模型。深度模型这步不要一开始就追求复杂结构先用我上面这个简化版HED跑通再逐步替换成RCF、UDEN等更复杂的模型。关于调试和时间分配我还有一个体会网络结构本身很少成为性能瓶颈真正花时间的是数据、损失函数和后处理。数据处理出了问题模型再先进也白搭损失函数的权重没调好网络往往会忽略小目标边缘后处理少了NMS和细化检测结果在视觉上就会显得“脏”。把这三个环节中最靠前的一个做好往往比换个更大的模型更有效。