简介:EDTER(Edge Detection with Transformer)是CVPR 2022提出的边缘检测方法,这份可运行源码包面向计算机视觉研究者和边缘检测入门开发者。EDTER利用Transformer捕捉图像长距离依赖,弥补传统CNN在全局上下文建模上的不足,适用于BSDS500、PASCAL VOC等数据集的训练与评估。
包体共6个文件,以Python脚本、Markdown说明、HTML分析页和结果演示图片为主,压缩包约44KB。内容包括EDTER演示程序、项目总结文档、分析图表以及Linux环境配置指引,可帮助读者快速理解EDTER的推理流程与实现要点。
目前已有110人学习浏览,适合希望复现SOTA边缘检测效果、研究Transformer在底层视觉任务中应用的读者。源码基于MMsegmentation框架实现,附有单/多GPU训练命令、单/多尺度测试脚本及复现指标对比,能够有效缩短论文复现和二次开发的时间。
1. 做边缘检测的工程师看到 EDTER 这个名字,最该关注的是什么
做边缘检测的工程师看到 EDTER 这个名字,最关心的其实就一件事:Transformer 到底给边缘检测带来了什么传统方法给不了的东西。Canny、HED 这类方案各有各的脾气,但到了真实照片的弱边缘、纹理密集区域,输出往往是两条路——要么断,要么躁。EDTER 的思路不是把 CNN 换成 Transformer 那么简单,它用全局分支去“看全局”,局部分支去“描细节”,再用双向融合把两者来回倒腾。下面从架构拆解、源码运行、训练微调和踩坑排查讲一遍,最后给一个验证模型水平的具体技巧。想用 Transformer 改进边缘检测,或者手头有官方代码但跑不顺的,这篇可以直接照着操作。
2. EDTER 的网络结构:为什么边缘检测要“两个分支来回倒腾”
边缘检测本质上是像素级二分类,但它比语义分割更尴尬:每个像素只回答“是边缘还是不是边缘”,可真正决定答案的往往不是这个像素本身,而是它周围很远的地方发生了什么。一段被草丛挡住的栏杆,局部看全是竖条纹,谁能分清哪条是栏杆、哪条是草?只有往更远看,看到栏杆的走向,才能把断裂处连起来。这就是边缘检测对“全局上下文”的真实需求,也是 EDTER 把 Transformer 请进来的直接原因。
2.1 全局分支与局部分支:一个负责看图,一个负责描边
EDTER 的做法是放两个分支,各干各的活。
全局分支先对图像做下采样,通常降到原图的 1/16 分辨率,然后切成 patch 序列交给 Transformer encoder。这里 patch size 常见取 16,也就是把 1/16 分辨率的特征图再按 16×16 的区域切块,整张图的序列长度大约等于 (H/16) × (W/16)。这个分支每一层都在做全局自注意力,它不擅长回答“边缘具体落在哪一个像素”,但非常擅长回答“这里应该有一条边”。比如一片被阴影打断的轮廓,只看局部谁都不敢连,但全局分支能看到远处同样的走向,就能把语义上的连续性判断出来。
局部分支则保持在较高分辨率上,用卷积逐步提取空间细节。它的任务是回答“边缘的精确位置在哪”,保留相位信息、避免轮廓被磨平。两个分支的特征分辨率差了很多,不能简单相加,所以 EDTER 在中间加了融合模块来协调。
这里有个容易误解的点:局部分支并不是独立跑一个 CNN 到死。真正的设计里,局部特征和全局特征会反复交换信息,局部分支的高分辨率细节反过来修正全局分支的注意力位置,全局分支的语义判断又指导局部分支该强化哪条边。两分支分离只是开始,融合才是重点。
2.2 双向融合:信息要双向流动,不是简单相加
很多两分支网络到最后把两个特征 concat 一下就完事,但边缘检测要求全局判断和局部定位同时成立,所以 EDTER 在多个阶段都放了双向融合模块。我用一句话概括它的行为:全局特征往下采样、上采样后和局部特征对齐,先做通道对齐,再相加或拼接,融合后的特征重新喂回两个分支,等待下一次融合。
这种“来回倒腾”的作用是:阶段越靠后,两个分支的特征越趋于一致。拿 FPN 做对比会更清楚:FPN 也是多尺度融合,但信息是自顶向下单向传播的;EDTER 的融合是双向交替,全局分支每更新一次,局部分支也跟着更新一次,而不是最后一次才合并。
输出头可以直接接在局部分支最后的高分辨率特征上,经过一层细化卷积把边缘宽度压到 1~2 个像素。这个“细化”操作很关键,直接二值化粗特征得到的边缘通常有三四个像素宽,视觉上很脏,做任务时也不好用。EDTER 源码里这一层通常是 3×3 卷积加 sigmoid,输出和输入分辨率一致。
2.3 为什么用 Transformer 而不是堆更深的 CNN
如果单纯追求大感受野,CNN 也能做到:不断下采样、堆卷积,到最后一个 stage 理论上能看到全图。但代价是特征图分辨率太低,细节早被磨平了。边缘检测恰恰是不能丢细节的任务,所以 EDTER 保留了一个高分辨率的局部分支,把长距离建模单独交给全局分支。
Transformer 在数据量足够时的优势很明显:每一层自注意力都在全图范围交换信息,不存在感受野缓慢扩张的问题。第一层就能看到全局,这对边缘断裂修复是直接的收益。但也要泼一盆冷水:Transformer 分支的序列长度和输入分辨率直接相关,所以全局分支只能在 1/16 分辨率下跑,否则显存会很快打满;这也决定了它面对特别小的目标时,边缘定位会偏粗,需要靠局部分支兜底。
提示:拿到 EDTER 源码后,第一件事就是确认预训练权重的来源。边缘检测数据集通常只有几百到几千张图,从零训练 Transformer 分支很容易过拟合。常见做法是用 ImageNet 预训练权重初始化主干,再在边缘检测数据上微调,这也是后面训练章节反复提到的基础。
3. 把 EDTER 可运行源码跑起来:环境、权重与单张推理
标题里带了“可运行源码”,说明这套代码不是论文配的残缺片段,而是能直接出结果的工程包。但“能运行”和“在你机器上能运行”是两回事。我的经验是:拿到源码先别急着装环境,把仓库结构、权重说明、入口脚本看明白,再动手。
3.1 拿到可运行源码后先做三件事
第一件事是看目录里有哪些 Python 文件,确认入口脚本叫 infer.py 还是 test.py;第二件事是查 README 里关于预训练权重的说明,确定权重是放在网盘、Release 附件还是需要单独下载;第三件事是找数据路径配置,很多源码会在 argparse 里写死默认路径,不改成你的本地路径,跑起来必报错。
# 1. 确认入口脚本 find . -maxdepth 2 -name "*.py" | sort # 2. 查预训练权重和运行说明 grep -rn "download\|pretrained\|weight\|ckpt" README.md | head -20 # 3. 查默认数据路径配置 grep -rn "data_root\|dataset\|root" *.py 2>/dev/null | head -20这三步花五分钟,能省掉后面两小时的玄学排错。尤其是第三步,不少新手把数据放到自定义目录后,直接运行,发现 loss 异常或者输出全黑,最后才查出来模型根本没读到自己的数据,读的是源码里写死的占位路径。
3.2 单张推理:如何加载预训练权重并输出边缘图
环境按常规 PyTorch 工程配就行。我一般会新开一个虚拟环境,避免和已有项目相互污染。依赖里除了 torch,还需要 opencv-python 和 pillow,一个用于读图,一个用于保存结果。
conda create -n edter python=3.9 -y conda activate edter pip install torch torchvision timm opencv-python pillow如果仓库自带 requirements.txt,直接pip install -r requirements.txt更省事。装完之后,先跑通单张推理。EDTER 这类边缘检测模型,推理入口大多是一个 Python 脚本,接受输入图像路径和权重路径。常见写法是这样:
import torch import numpy as np from PIL import Image import torchvision.transforms as T # 如果源码里已有模型定义,用仓库里的导入方式 # 下面的 EDTER 请替换成你仓库里实际的模型类名 from model import EDTER device = "cuda" if torch.cuda.is_available() else "cpu" # 加载权重 checkpoint = torch.load("weights/edter_pretrained.pt", map_location="cpu") if isinstance(checkpoint, dict) and "state_dict" in checkpoint: state_dict = checkpoint["state_dict"] else: state_dict = checkpoint # 去掉 DataParallel 额外加的 module. 前缀 state_dict = {k.replace("module.", ""): v for k, v in state_dict.items()} model = EDTER() model.load_state_dict(state_dict) model = model.to(device).eval() transform = T.Compose([ T.Resize((512, 512)), T.ToTensor(), T.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) img = Image.open("demo.jpg").convert("RGB") x = transform(img).unsqueeze(0).to(device) with torch.no_grad(): out = model(x) # 不同仓库的返回结构不一样,这里做兼容 if isinstance(out, (tuple, list)): pred = out[-1] elif isinstance(out, dict): pred = out.get("edge", list(out.values())[-1]) else: pred = out pred = torch.sigmoid(pred).squeeze().cpu().numpy() # 可视化前做归一化,注意这只用于肉眼观察 pred = (pred - pred.min()) / (pred.max() - pred.min() + 1e-6) Image.fromarray((pred * 255).astype(np.uint8)).save("demo_edge.png")这段代码里,EDTER()的构造方式要按源码来,有些仓库会预留build_model()之类的函数,那直接用仓库的构造更方便。权重加载做了两层兼容:一是兼容 checkpoint 里带state_dict字段的情况,二是去掉module.前缀,这对应了分布式训练保存的权重。
pred拿到的是模型原始输出,边缘检测模型最后一般接 sigmoid,所以输出范围是 0 到 1。保存前做 min-max 归一化只是为了可视化时对比度高一点,真正做评测时不能用这种归一化,否则每个图的阈值都在变,指标会虚高。这点后面避坑章节还会再提。
3.3 批量推理与图像尺寸处理
单张跑通后,批量只是套一层循环。常见做法是遍历输入目录,把每张图的结果写到输出目录。
mkdir -p outputs for img in ./inputs/*.jpg; do name=$(basename "$img" .jpg) python run_infer.py --input "$img" --output "outputs/${name}_edge.png" done这里把 3.2 的脚本存成run_infer.py,并加上 argparse 参数就能直接用。更省事的方案是把推理逻辑封装成一个函数,在主程序里循环调用,省去反复启动 Python 进程的开销。
尺寸处理是批量推理最容易翻车的地方。边缘检测模型通常固定在 512×512 或 384×384 上训练,测试时直接 resize 会改变边缘的宽窄,特别是细边缘可能直接被插值抹掉。我一般会保持宽高比缩放,把长边压到 512,短边补零到 512,或者用 padding 而不是直接拉伸。改完输入尺寸后,输出记得裁回原图区域。如果不想处理这些,也可以直接用仓库自带的测试脚本,但同样要留意它对图像尺寸的处理策略,不要无脑相信默认参数。
4. 用自己的数据微调 EDTER:数据准备、训练超参与收敛判断
跑通推理只是第一步,真正想让 EDTER 在自己的业务数据上可用,必须微调。预训练权重在 BSDS500 这类自然图像上表现不错,但换到文档扫描、医学影像、工业零件表面,分布一变,边缘质量立刻下降。这一章按数据、命令、收敛判断三层讲。
4.1 数据准备:BSDS500 的目录结构和标签检查
微调 EDTER 最常见的数据集是 BSDS500,它包含 200 张训练图、100 张验证图和 200 张测试图,每张图都有对应的边缘标注。这个数据集的常见预处理是:图像放在image/,边缘标签从原始的.mat文件转成单通道 PNG 放在edge/,标签里边缘像素是 255,背景是 0。
data/BSDS500/ train/ image/ 0001.jpg ... edge/ 0001.png ... val/ image/ ... edge/ ...拿到源码后,先检查一下标签的形状和范围,避免读进来才发现标签是三通道或者值域不对。
import cv2, glob images = sorted(glob.glob("data/BSDS500/train/image/*.jpg")) labels = sorted(glob.glob("data/BSDS500/train/edge/*.png")) assert len(images) == len(labels), "图像和标签数量不一致" for im, lb in zip(images[:5], labels[:5]): img = cv2.imread(im) lab = cv2.imread(lb, cv2.IMREAD_GRAYSCALE) assert img.shape[:2] == lab.shape, f"{im} 和标签尺寸不一致" print(im, img.shape, lab.shape, lab.min(), lab.max())这段代码只检查前五对,确认图片和标签分辨率是对齐的,标签值域是 0 到 255。很多预处理脚本输出的标签是 0 和 1,也没问题,喂给损失函数之前统一处理即可。如果标签里有非 0、非 255 的中间值,说明边缘标注带了灰度过渡,这时需要先做二值化,否则损失函数会被中间值搞糊涂。
用自己的数据时,常见做法是标注成和 BSDS500 一致的单通道 PNG,边缘为白底黑边或黑底白边都行,但要保持一致。标注工具不用太复杂,Polygon 类工具导出掩码后,再用形态学细化提取边缘即可。
4.2 微调命令与关键超参数
数据就位后,训练命令按仓库入口脚本的写法调整。下面是一个常见的微调命令示例,具体参数名以你仓库的train.py --help为准:
python train.py \ --data_root ./data/BSDS500 \ --dataset bsds \ --pretrained ./weights/edter_pretrained.pt \ --batch_size 8 \ --lr 1e-4 \ --epochs 60 \ --crop_size 384 \ --out_dir ./runs/edter_finetune关键超参数的选择逻辑,我习惯用下面这个表:
| 参数 | 推荐范围 | 说明 |
|---|---|---|
| 学习率 | 1e-4 到 5e-5 | 边缘检测是 dense prediction,学习率太大容易震荡 |
| batch size | 4 到 8 | 取决于显存,Transformer 分支很吃显存 |
| crop size | 384 到 512 | 越大越保留细节,但显存和训练时间线性增长 |
| epochs | 60 到 120 | BSDS 数据量小,60 轮左右基本够,过多会过拟合 |
| 损失函数 | BCE + Dice | 边缘像素占比极低,纯 BCE 会偏向预测背景 |
训练时默认使用预训练权重,而不是从零开始,尤其是全局分支。从零训练 Transformer 分支需要的数据量远超过边缘检测数据集能提供的量,强行训练的结果通常是验证集 ODS 上不去,边缘输出一片模糊。
如果显存不够,batch size 降到 2,同时把 crop size 降到 320,通常能解决。EDTER 的全局分支在 1/16 分辨率上计算自注意力,输入越大序列越长,显存压力主要来自这里。
4.3 从 loss 曲线和验证集判断训练状态
训练过程中不要只看 loss 数字,要同时盯三个信号。第一,loss 是否在前 5 个 epoch 内从初始值明显下降,如果完全不动,先怀疑数据没读对,再怀疑学习率;第二,验证集的 ODS 是否在同步上升,如果训练 loss 下降但验证指标不动,说明在过拟合;第三,偶尔保存一两张验证图,肉眼看一下边缘宽度是否正常,边缘结果如果粗成一团,可能是 loss 权重配比有问题。
关于损失函数,边缘检测有个天然问题:一张 512×512 的图里,边缘像素通常只占 5%~10%,普通 BCE 会让模型学会“全预测成背景”,因为这样 loss 也很低。常见做法是给边缘像素加权重,或者叠加 Dice Loss,让模型不能靠牺牲少数类来刷指标。EDTER 相关源码里损失函数大多是 BCE 的变体,微调时建议直接使用仓库默认设置,先跑通,再根据自己数据集的边缘占比决定要不要调整权重。
验证阶段的评估指标,BSDS500 上常用 ODS、OIS 和 AP,其中 ODS 是固定阈值下的最优 F1,OIS 是每张图各自最优阈值下的 F1。微调后如果 ODS 比预训练权重低 0.05 以上,先别急着调模型结构,回头看学习率和数据增强,常见问题是学习率太高把预训练权重洗掉了。
注意:边缘检测模型微调和分类模型微调有个重要区别——分类模型可以冻结 backbone 只训分类头,边缘检测的输出头和 backbone 高度耦合,通常建议用较小学习率全量微调,而不是只训最后几层。全量微调时,初始学习率一定要比分类任务低一个量级。
5. EDTER 实战避坑:五个高频故障和排查顺序
这一章写的是真正影响出结果的坑,每条都是现象、原因、解决三步走。遇到过这些问题的读者会发现,大多数“模型不行”其实不是模型的问题,是数据、后处理或加载环节出了岔子。
5.1 输出全黑或全白:先别怀疑网络,查后处理
现象:加载预训练权重跑一张图,输出的边缘图要么全黑,要么全白,完全看不出轮廓。
原因:最常见的是后处理值域搞错了。模型输出经过 sigmoid 后是 0 到 1 的浮点数,如果直接用(pred * 255).astype(np.uint8)保存,而模型输出没有做归一化,大部分值会落在 0 附近,图就全黑。反过来,如果代码里先做了 min-max 归一化,又叠加了错误的阈值,也可能全白。另一种可能是预训练权重没有正确加载,模型还在随机初始化状态,输出自然没有结构。
解决:先确认权重加载成功,打印模型第一层卷积的权重均值,随机初始化和预训练初始化差别非常大。再确认 sigmoid,有些仓库的模型 forward 里已经带了 sigmoid,有些没带,重复套 sigmoid 会把输出压到接近 0 或 1,边缘细节全部丢失。最后,保存可视化结果时再考虑 min-max 归一化,但要清楚这只是为了看效果,不能用于评测。
5.2 loss 一直不降:数据分布和标签格式比学习率更容易出问题
现象:训练了十几个 epoch,训练 loss 始终在一个高位徘徊,比如 0.7 左右不动,验证集输出几乎没有边缘。
原因:这个现象我排查过很多次,最后大多不是学习率的问题,而是标签读取出了问题。标签是三通道图、标签值域是 0 到 255 没有归一化、图像和标签没有做同样的缩放,都会让模型学到混乱的映射。尤其是边缘标签很稀疏,如果标签读取时被自动归一化到 0 到 1,而模型输出是 0 到 1,那 BCE Loss 还能正常工作;但如果标签还是 0 到 255,BCE 会认为每个像素都是“强烈正样本”,loss 自然高居不下。
解决:先做 4.1 里的标签检查脚本,确认读进来的数组范围。再把标签归一化到 0 到 1,并和图像做完全相同的尺寸变换。这两步确认后,如果 loss 还是不降,再考虑学习率:EDTER 微调用 1e-4 起步是合理的,过大容易震荡,过小前几十个 epoch 看不到变化。还有一个常见设置是加线性 warmup,前 3~5 个 epoch 把学习率从 0 慢慢升到目标值,Transformer 分支对突然的大学习率非常敏感。
5.3 高分辨率图片显存爆炸
现象:推理时输入一张 2000×3000 的图,直接报 CUDA out of memory,训练时 batch size 设 8 也爆显存。
原因:EDTER 的全局分支要把 1/16 分辨率的特征图切成 patch 序列,图像越大,序列越长,自注意力的显存开销是序列长度的平方级别。2000×3000 的图下采样到 125×187,patch size 16,序列长度大约 90 个 patch,看似不多,但 Transformer 层的 hidden size 和层数会迅速放大显存占用。
解决:推理时把长边缩到 1024 以内再送进模型,输出边缘图后再放大回原尺寸;如果业务必须用原分辨率,就分块预测,但分块会导致边缘不连续,需要后处理拼接。训练时优先降低 crop size 而不是 batch size,因为 crop size 直接决定序列长度,对显存影响更直接。还可以开启混合精度训练,对 Transformer 分支的效果一般都不错,显存能省 30% 左右。
5.4 换到自己的数据后边缘断裂严重
现象:在 BSDS500 上效果正常的模型,换到自己的数据上,边缘要么断成一截一截,要么把纹理误检成边缘。
原因:预训练权重见过的是自然图像边缘,你的数据如果是文档、遥感图或医学切片,边缘风格完全不同。另一个原因是后处理阈值固定不变,BSDS500 上试出来的阈值不一定适合你的数据,输出响应普遍偏低时,高于阈值的部分就断裂了。
解决:先用较低阈值(比如 0.2)保存一张可视化图,看模型的原始响应是否连续。如果低阈值下边缘完整,说明模型学到了边缘,只是响应偏弱,这时应该做微调而不是改后处理;如果低阈值下边缘依然断裂,说明数据分布差异太大,需要收集几十张目标数据做微调,哪怕只有 50 张,也比直接用预训练权重强。微调时保持边缘标签的连续性很重要,下采样时不要把细边缘切没,我的习惯是训练时先对图像和标签做同步随机裁剪,而不是直接缩放整图。
5.5 微调后反而打不过预训练权重
现象:微调了几十个 epoch,ODS 比加载的预训练权重还低 0.03 以上,输出边缘明显变糊。
原因:典型的小数据过拟合,或者学习率太大把预训练知识洗掉了。边缘检测数据集通常很小,EDTER 这种参数量大的模型,全量微调很容易记住训练集,而验证集指标反而不如预训练。
解决:先确认学习率,微调时超过 5e-4 基本就要出事。再确认训练轮数,60 轮后如果 ODS 开始回落,说明过拟合已经发生,应该早停而不是跑满全部 epoch。还可以给全局分支加一点权重衰减,同时用验证集 ODS 作为模型保存的判定标准,保存指标最高的 checkpoint,而不是最后一轮。如果数据实在太小,就把预训练权重的全局分支前几层冻结,只微调局部分支和融合模块,这样能明显降低过拟合风险。
6. 验证边缘检测器的实战技巧:从 ODS 指标到带容差的 F1 检查
BSDS500 的 ODS/OIS/AP 是通行指标,但它们是个黑匣子,只能告诉你“这个模型整体行不行”,不能告诉你“断边多不多、边缘宽不宽、误检集中在哪”。我的习惯是:用标准指标确认基线,再用自建的小评测集做定向验证。
6.1 ODS/OIS/AP 之外还要看什么
ODS 是固定阈值下的最优 F1,OIS 是每张图各自最优阈值下的 F1。两个指标都有个共同问题:它们对边缘宽容度做了隐式处理,但看不出视觉质量。一张边缘宽度三像素的图和一张一像素的图,只要覆盖范围差不多,F1 可能接近,但视觉差距巨大。
所以我会额外做两件事:第一,用同一个固定阈值输出所有预测图,人工扫一遍,重点关注长边缘是否连续;第二,计算预测边缘的平均宽度,理想情况是 1~2 个像素,超过 3 个像素就说明细化层没学好,或者后处理缺少非极大值抑制。
6.2 用带容差的 F1 快速验证边缘质量
边缘检测的标签本身有标注误差,严格逐像素对比没有意义,常规做法是给预测图做少量膨胀,容忍 2 像素左右的偏移。下面这段代码可以作为自建评测集上的统一验证工具:
import cv2 import numpy as np def edge_f1(pred_path, gt_path, tolerance=2, thr=128): pred = cv2.imread(pred_path, cv2.IMREAD_GRAYSCALE) gt = cv2.imread(gt_path, cv2.IMREAD_GRAYSCALE) pred = (pred > thr).astype(np.uint8) gt = (gt > thr).astype(np.uint8) kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)) pred_dil = cv2.dilate(pred, kernel, iterations=tolerance).astype(bool) tp = np.sum(pred_dil & gt.astype(bool)) fp = np.sum(pred_dil & ~gt.astype(bool)) fn = np.sum(~pred_dil & gt.astype(bool)) prec = tp / (tp + fp + 1e-6) rec = tp / (tp + fn + 1e-6) f1 = 2 * prec * rec / (prec + rec + 1e-6) return round(f1, 4)这里的逻辑是:预测边缘膨胀 2 像素后,只要碰到真值边缘就算命中;真值边缘没有被膨胀后的预测覆盖,就算漏检。tolerance=2是常用值,如果你的标签边缘本身很粗,可以设成 1。运行前确认两张图尺寸一致,否则先对齐尺寸再算。
用这个函数对比不同模型时,我的习惯是固定 20 张难例图,每张图人工确认边缘主体,然后跑一遍 F1。这比盯着单一 ODS 值更能反映真实业务表现。最后的血泪经验是:任何时候都不要在评测前去对预测图做 min-max 归一化,它会掩盖阈值选择问题,让指标虚高。正确的做法是固定一个阈值(比如 0.5)或者用 OIS 的思路每图选阈值,但必须统一规则,否则对比就没有意义。边缘检测调参是个反复的过程,希望这篇笔记能帮你把时间花在真正影响结果的地方。
本文还有配套的精品资源,点击获取