基于YOLOv11的遥感道路提取与变化检测实践
2026/9/17 9:51:51 网站建设 项目流程

简介:一份面向计算机视觉与遥感应用学习者的技术方案文档,围绕YOLOv11在卫星遥感图像道路提取与变化检测中的应用展开。内容系统梳理了YOLO系列算法演进,重点讲解YOLOv11的骨干网络、颈部网络、检测头及损失函数,并分别给出道路提取与变化检测两个子方案:从数据采集、预处理、模型初始化与训练,到非极大值抑制、图像配准、变化类型识别和结果可视化,覆盖完整落地流程。后续还包含实验设计、结果分析以及城市规划、交通管理、灾害评估等应用场景,结构清晰、理论与实践并重,适合需要完成相关课题或工程验证的读者参考。文档共31页,压缩包仅含1个PDF文件,体积2.12MB;目录与章节可快速跳转,方便按需阅读。目前已有90人学习下载。

1. 为什么说“基于YOLOv11”可以同时做道路提取和变化检测?

拿到一批亚米级卫星影像,老板要求一周之内把新增道路和断头路位置标出来。传统做法是训练两个模型:一个用语义分割做道路提取,一个用孪生网络做变化检测。部署链路长,显存开销大,两个模型的目标空间还不一致,对齐时经常要手写坐标转换。真实工程里更现实的做法是,用 YOLOv11 的实例分割能力先把道路段完整切出来,把每条路当成一个有独立掩码和置信度的实例,再用两期检测结果做匹配,这样“提取”和“变化检测”可以在同一套特征抽取器上完成。标题里的方案并不是把 YOLOv11 包装成一个万能分割器,而是让模型输出可被下游比较的结构化目标,把“这一期有、下一期没有”变成实例之间的集合差。这条路径适合已经会用检测/分割模型、手里有遥感瓦片数据、希望快速得到可叠加 GIS 图层的团队。需要先说明的是,如果目标是稠密街区的完整路网,YOLO 类检测框会相互遮挡,这种方案会吃亏,正文你会看到边界。

2. 准备数据与YOLOv11实例分割训练,把道路提取落成可迭代的工程

2.1 数据集从哪里来:把语义掩码转成道路段实例

遥感道路数据有两种常见形态。第一种是公开道路分割数据集,标签是与影像同尺寸的二值掩码,道路像素为255;第二种是自有业务数据,来自矢量测绘或人工标注,最终要转成道路面。我一般不会直接把掩码喂给 YOLO,因为 YOLO 分割训练需要的是多边形实例标注,而不是语义掩码。两条平行道路在语义掩码里连成一个连通域,直接训练会被当成一条路,后续变化检测的“新增/消失”统计就完全失真。第一步必须把掩码转成道路段实例:按连通域拆分,保留面积和长宽比符合道路特征的连通域,再转成 YOLO seg 格式的 txt。

下面是一个轻量转换函数,用findContours提取轮廓,用 RDP 算法简化点数量。

import cv2 import numpy as np def mask_to_yolo_seg(mask_path, img_w, img_h, txt_out, min_area=200): mask = cv2.imread(mask_path, cv2.IMREAD_GRAYSCALE) kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)) mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel, iterations=2) contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) lines = [] for cnt in contours: area = cv2.contourArea(cnt) if area < min_area: continue approx = cv2.approxPolyDP(cnt, 1.0, True) pts = approx.reshape(-1, 2).astype(np.float32) pts[:, 0] /= float(img_w) pts[:, 1] /= float(img_h) seg = " ".join([f"{x:.6f} {y:.6f}" for x, y in pts]) lines.append("0 " + seg) with open(txt_out, "w", encoding="utf-8") as f: f.write("\n".join(lines)) # 示例:掩码和原图同尺寸,输出到 labels/ 目录 mask_to_yolo_seg("data/tile_001_mask.png", 1024, 1024, "labels/tile_001.txt", 500)

参数说明:min_area过滤掉面积小于该像素值的噪声连通域,一般道路在 1024 瓦片里至少占几百像素,取 200 到 500 都可以,取决于道路宽度;approxPolyDP的第二个参数1.0是点到拟合线的最大距离,值越大轮廓点越少,道路交叉口处越容易粘连,我建议先设置 1.0,看生成的 txt 可视化后再决定是否提高到 2.0。img_wimg_h必须跟原图尺寸一致,如果掩码和影像分辨率不一致,先缩放掩码再做轮廓提取,否则归一化坐标会整体偏移。转换完成后,按 Ultralytics 惯例放置数据集:images/存原始影像,labels/存分割标注,训练集、验证集分开,data.yaml里声明路径和类别0: road

注意:不要把道路中心线直接当掩码训练。中心线是“线”不是“面”,经过 YOLO 的下采样和 mask 分支压缩后很容易丢失。若最终要中心线,应对训练好的道路掩码做骨架提取,而不是让模型直接回归线目标。

2.2 环境配置与最小可运行目录

YOLOv11 的环境配置比旧版 YOLO 简单很多,不需要手动下载 cfg 和分类权重文件。拿到一台有 NVIDIA 显卡的机器,先建一个 Python 3.10 环境,再安装 Ultralytics。

python -m venv .venv source .venv/bin/activate pip install -U ultralytics

这一步会把 PyTorch、OpenCV 一起装好。如果你的 CUDA 是 11.8 或 12.1,建议先到 PyTorch 官网按对应命令安装torchtorchvision,再装ultralytics;否则 pip 默认装的 torch 版本可能与驱动不匹配。遥感影像的瓦片通常较大,训练阶段分辨率建议从 640 起步,确认数据链路通了之后,再升到 1024 或 1280。推理阶段可以无限切块,训练尺寸不等于推理尺寸,这一点后面细说。

为了实验可追溯,我习惯把每次运行的任务名写成可读形式,例如road_y11_1024_lr1e-3,并保证同一个数据集版本固定。Ultralytics 的data.yaml内容如下:

path: /data/road_extraction train: images/train val: images/val test: images/test names: 0: road

2.3 训练自己的道路数据集:CLI 命令与关键参数

训练一个最小可用的道路提取模型,命令如下:

yolo segment train \ model=yolo11n-seg.pt \ data=config/data.yaml \ epochs=80 \ imgsz=1024 \ batch=8 \ device=0 \ workers=4 \ patience=20 \ project=exp \ name=road_yolo11

这里model=yolo11n-seg.pt是 COCO 预训练权重,遥感影像与自然图像差异大,很多人担心预训练没用。实际收敛速度仍然比从零训练快很多,backbone 底层的边缘和纹理特征是可迁移的。如果担心过拟合,可以先用freeze=5冻结前 5 层主干,训练 20 轮后再解冻。imgsz=1024是速度和精度的折中,道路宽度只有几个像素时建议升到 1280,但显存也会跟着涨。patience=20用于早停,避免验证集 mAP 长时间不涨时继续空跑。训练完成后,权重保存在exp/road_yolo11/weights/下,评估和推理都使用best.pt

在实际调参中,下面这张表可以作为一个起点:

参数推荐初值调整建议
imgsz1024道路细、断裂多时升到 1280/1536;显存不够降到 640
epochs80数据量小且增强多时增加到 120
batch8确保总样本量不变,显存不够时降低 batch 并增大 epochs
lr00.01小数据集用 0.001,大数据集用 0.01
mask_ratio1.0道路宽高比极大时,增大 mask 分辨率或提升 mask 损失权重
scale0.5影像尺度差异不大时改成 0.25,避免道路被过度缩放

YOLOv11 的网络结构里,backbone 使用了 C3k2 和 SPPF,检测头之外还有独立的 mask 分支。遥感道路是细长目标,如果直接照搬 COCO 的超参,很容易出现掩码边缘锯齿。我一般会把分割损失的权重调高到检测损失的 1.5 到 2 倍,具体做法是在训练代码中修改 loss 项的权重。观察训练输出的 mask 可视化图,如果目标掩码被压成了一团,说明 mask 分辨率太低,优先提高imgsz,而不是盲目增加数据量。

3. 用训练好的模型批量提取道路,并把推理结果保存成可叠加的瓦片

3.1 预测后保存:一次推理得到可视化、掩码和裁剪图

模型训练完成后,批量推理的命令可以写成:

yolo segment predict \ model=exp/road_yolo11/weights/best.pt \ source=val/images \ imgsz=1024 \ conf=0.25 \ iou=0.45 \ save_txt=True \ save_conf=True \ save_img=True \ save_crop=True \ project=inference \ name=road_predict

这条命令会把source下所有瓦片都跑一遍,结果输出到inference/road_predict/save_txt=True会在labels/下生成与图片同名的 txt,每一行代表一个道路实例;save_conf=True会把置信度写入文件;save_crop=True会把每个道路目标的外接矩形单独裁出来,方便人工抽查漏检和误检。conf=0.25对遥感道路偏保守,树荫和阴影里的支路可能只有 0.15 左右,我一般先设 0.15,跑完看误检率再逐步上调。iou=0.45控制 NMS 的抑制程度,道路交叉口目标重叠非常大,默认值会把垂直相交的两条路吞掉一条,降到 0.3 能明显改善交叉口召回。

3.2 保存推理结果:从归一化掩码坐标到 GeoJSON

如果推理结果只停留在 PNG,拿到 GIS 里还是没法叠加。遥感瓦片一般带有仿射变换参数,最常见的做法是用rasterio读原始瓦片的 transform,把 YOLO 输出的归一化掩码坐标换算成地理坐标,再写成 GeoJSON。

import json import numpy as np import rasterio from pathlib import Path def yolo_seg_to_geojson(img_path, label_path, out_geojson): with rasterio.open(img_path) as src: transform = src.transform width, height = src.width, src.height features = [] with open(label_path, "r", encoding="utf-8") as f: for line in f: parts = line.strip().split() if len(parts) < 8: continue conf = float(parts[6]) raw = np.array([float(p) for p in parts[7:]], dtype=np.float32) nx, ny = raw[0::2], raw[1::2] px, py = nx * width, ny * height coords = [] for i in range(len(px)): x, y = px[i], py[i] gx, gy = transform * (x, y) coords.append([gx, gy]) features.append({ "type": "Feature", "properties": {"conf": conf}, "geometry": {"type": "Polygon", "coordinates": [coords]} }) with open(out_geojson, "w", encoding="utf-8") as f: json.dump({"type": "FeatureCollection", "features": features}, f) # 遍历推理目录中的 labels for label_path in Path("inference/road_predict/labels").glob("*.txt"): img_path = Path("val/images") / (label_path.stem + ".png") yolo_seg_to_geojson(img_path, label_path, f"geojson/{label_path.stem}.geojson")

这里有一个非常容易踩坑的地方:parts[6]是置信度字段的前提是save_conf=True。Ultralytics 的 txt 行格式为class x1 y1 x2 y2 conf mask...,下标 6 正好是置信度;如果关闭save_conf,下标 6 变成第一个掩码坐标,解析会错。掩码坐标是归一化的,需要先乘以图像的宽高,再做仿射变换。transform * (x, y)会把像素坐标转到地理坐标,如果瓦片没有地理参考,这一步可以省略,输出本地坐标即可。

3.3 瓦片接边:同一条路别出现在两个瓦片里

滑窗推理带来的最大问题是接边。两个相邻瓦片重叠 20% 时,同一条道路会被检测两次,直接导致变化检测里重复计数。我通常先对 GeoJSON 做一步合并:用shapelyunary_union把重叠的 Polygon 合并,同时保留置信度最高的那条属性。如果不想引入新依赖,也可以用道路掩码的骨架线做距离匹配,把两个瓦片里距离小于 1 个像素的线段合并。这一步不做,后面无论检测精度多高,变化检测的结果都不可信。

4. 双时相检测结果如何落到变化检测:框匹配与掩码差分

4.1 为什么不用整图像素差分

卫星影像两期之间可能存在光照、植被和传感器角度差异,直接对两期影像做像素差分,会得到大量伪变化。基于 YOLOv11 的方案是先提取道路目标,再比较“前一期有、后一期没有”的道路段。变化检测的三种典型输出是:新增道路、消失道路、改建道路。由于模型输出的是实例分割掩码,变化检测就变成了一个集合比较问题:同一坐标附近是否出现相同方向的道路实例。

流程上,我用同一套best.pt分别推理两期瓦片,得到两个时期的 GeoJSON 集合。然后需要判断哪些道路实例是同一个实体。这里不能直接做多边形 IoU,因为同一路段在两期影像中可能偏移几米,单车道和改造后的双车道外观差异大,IoU 可能只有 0.1 甚至更低。更稳妥的是计算两个实例的中心距离和主轴方向,用这两个条件做软匹配。

4.2 掩码匹配变化检测的核心代码

下面的函数从一个 GeoJSON 中读取道路实例,计算每个实例的质心、主方向和近似长度,然后在两期之间做最近邻匹配。

import json import math import numpy as np def load_geojson(path): with open(path, "r", encoding="utf-8") as f: return json.load(f)["features"] def polygon_stats(coords): coords = np.array(coords) xs, ys = coords[:, 0], coords[:, 1] cx, cy = xs.mean(), ys.mean() dx = xs - cx dy = ys - cy cov = np.cov(np.stack([dx, dy], axis=1).T) eigvals, eigvecs = np.linalg.eigh(cov) angle = math.degrees(math.atan2(eigvecs[1, -1], eigvecs[0, -1])) length = math.hypot(xs.max() - xs.min(), ys.max() - ys.min()) return (cx, cy, angle, length) def match_roads(prev_features, curr_features, dist_thresh=15.0, angle_thresh=30): changes = [] used = [False] * len(curr_features) for p in prev_features: sp = polygon_stats(p["geometry"]["coordinates"][0]) matched = None for i, c in enumerate(curr_features): if used[i]: continue sc = polygon_stats(c["geometry"]["coordinates"][0]) dist = math.hypot(sp[0] - sc[0], sp[1] - sc[1]) angle_diff = abs(sp[2] - sc[2]) % 180 if angle_diff > 90: angle_diff = 180 - angle_diff if dist < dist_thresh and angle_diff < angle_thresh: matched = i used[i] = True break if matched is None: changes.append({"type": "removed", "geom": p["geometry"]}) for i, c in enumerate(curr_features): if not used[i]: changes.append({"type": "added", "geom": c["geometry"]}) return changes # 示例:t0 为早期影像,t1 为后期影像 changes = match_roads( load_geojson("geojson/t0.geojson"), load_geojson("geojson/t1.geojson") )

这段代码里的dist_threshangle_thresh是变化检测的主要旋钮。dist_thresh不能拍脑袋定,要根据影像分辨率换算:0.5 米分辨率影像中,同一条道路中心线偏移 3 米已经算重建,dist_thresh可以设 3.0;2 米分辨率影像可以放宽到 10 到 15 米。angle_thresh取 30 度是因为瓦片切分后,弯曲道路的主方向可能与整体方向不一致,角度限制太严会漏匹配。如果两个多边形中心距离在阈值内但方向差 30 度以上,大概率不是同一条路,而是交叉口附近的错检。

如果需要检测“改建”类型,只做框匹配不够。匹配成功后,计算两个掩码之间的双向 Hausdorff 距离,如果距离超过道路宽度的一半,说明路段发生了明显偏移,可以标记为改道。

4.3 变化检测的后验证与误报控制

匹配之后,变化列表里会有不少“消失”目标,其中一部分是检测器的偶发漏检。比如前一期的某个路段置信度只有 0.2,在后一期没被检测出来,就会被误判成消失。因此需要两道闸门:第一,只保留两期检测置信度都高于一定阈值的实例;第二,过滤面积过小的碎片。当imgsz=1024时,道路掩码面积通常应在 500 像素以上,低于 200 的零散检出一半是建筑物边缘。如果业务对细支路召回要求高,面积阈值降到 200,但要在变化列表里额外标记“低置信度候选”,交给人工复核。

我还会做一步掩码差分验证:把匹配到同一实例的两期掩码分别栅格化,做异或,计算异或区域占两个掩码并集的比例。如果比例超过 20%,说明该道路不仅位置变,形状也变化明显,应列入“改建”而不是“不变”。这比单纯比较检测框稳定很多,能避免光照变化引起的边缘抖动。

公开的森林变化检测数据集虽然目标不是道路,但其中包含大量“前后时相 + 变化掩码”的配对样本,可以用来检验这套双时相匹配流程的稳定性。直接拿道路模型推理森林影像没有意义,但数据集的时间配准和空间对齐格式可以复用。

5. 从切片到注意力:YOLOv11 小目标优化的几个优先项

5.1 切片推理:比改结构收益更明显的优化

遥感大图直接喂给 YOLOv11 会被压缩到 1024,很多细路直接消失。我一般先做切片推理:把大图切成 1024×1024,步长 768,保留 20% 重叠边界,将每个瓦片归一化坐标回贴到原图。下面这段代码是核心逻辑:

def sliding_window_predict(model, big_img, size=1024, stride=768): h, w = big_img.shape[:2] results = [] for y in range(0, h, stride): for x in range(0, w, stride): y2 = min(y + size, h) x2 = min(x + size, w) tile = big_img[y:y2, x:x2] r = model.predict(tile, imgsz=size, conf=0.15, iou=0.3, verbose=False) for res in r: for box in res.boxes: x1, y1, x2b, y2b = box.xyxy[0].tolist() results.append((x + x1, y + y1, x + x2b, y + y2b, box.conf.item())) return results

stride小于size是为了让目标跨界时至少有一次完整出现在某个瓦片里。切片推理之后还要做一次全局 NMS,否则重叠区的重复框会进入变化检测。对道路这种条带目标,按中心距离做聚类更合适,把中心距离小于 20 像素、方向角差小于 15 度的检测合并,保留置信度最高者。

5.2 网络结构与损失改进:自注意力、CARAFE 与 PIOUv2

如果切片和高分辨率输入都做了,仍然漏长距离直线,再考虑改 YOLOv11 网络结构。YOLOv11 的 backbone 已经包含 C3k2 和 C2PSA,后者是轻量自注意力模块,能增强长距离上下文。对道路这种跨大尺度连续的目标,可以在主干最后一层之后再加一层自注意力机制,增强道路段的连续性。上采样方面,CARAFE 比默认的最近邻上采样保留更多边缘细节,适合替换 neck 中的上采样算子。这些改动每次只加一个,参数量增幅控制在 5% 以内,否则小数据集上很容易过拟合。损失函数方面,PIOUv2 对高宽比极大的目标有更好的边界回归效果,可以在 Ultralytics 的自定义训练代码中替换原有的 box loss。

5.3 一个更可信的验证技巧:基于骨架线的 95 分位 Hausdorff 距离

变化检测的精度评估很难只用 mIoU,因为道路细长,一点点偏移就会让 IoU 掉到零,但这个结果其实并不影响业务判断。我通常用骨架线端点的 95 分位 Hausdorff 距离来评估变化区域定位质量。

import numpy as np from scipy.spatial import cKDTree def hausdorff_95(pts1, pts2): if len(pts1) == 0 or len(pts2) == 0: return float("inf") tree = cKDTree(pts2) dist, _ = tree.query(pts1) return np.percentile(dist, 95)

把预测变化区域和人工标注变化区域分别做骨架提取,再计算这个指标。当 95 分位距离小于 1.5 个像素时,说明变化区域定位准确;大于 3 个像素,说明边界抖动或匹配阶段错位。相比逐像素 IoU,这个指标对道路这种细长目标更稳定,每一轮切片、注意力或损失改进之后,跑一遍这个数字,比只看最后的 mAP 更能反映变化检测的真实质量。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询