YOLOv9遥感烟囱检测:从影像批量采集到坐标回算的完整实践

发布时间:2026/9/9 5:26:12
YOLOv9遥感烟囱检测:从影像批量采集到坐标回算的完整实践 先说清楚一个事这个项目做的不是“给一张图让模型猜有没有烟囱”的玩具Demo而是一条完整的自动化链路——从按经纬度批量采集Google Earth影像到整理成训练集再到用YOLOv9把烟囱目标训练出来最后落到一个能批量扫描、结果可视化的检测系统上。整条链路里最耗时间、最容易翻车的不是YOLOv9训练这一步反而是“怎么稳定、批量、不出偏移地拿到影像”和“怎么把标注数据整理到能训练的状态”这两个环节。这篇文章把我从零搭建这套系统的完整过程、踩坑记录、参数调整经验都写出来给正准备做类似遥感目标检测项目的读者一个参考。1. 先搞清楚这套系统解决的是什么问题1.1 一个真实的排查场景做这个项目的起因是环保监测方向的业务需求需要在较大范围内快速摸清有多少个烟囱类高架点源以及它们的大致空间分布。传统方式靠人工在Google Earth里一个个用肉眼找、手动打点一个城市范围的影像看下来眼睛基本就废了而且不同人找出来的结果还不一样很难标准化。换个思路就能自动化把地图影像按网格切成一张张图片让检测模型自动判断每张图里有没有烟囱、有的话具体在哪个坐标位置。这个思路单独看不算新鲜真正难的是落到工程上——影像从哪来、分辨率够不够、能不能批量拉取、拉下来怎么和坐标对应。这套系统的价值就是把“影像批量获取目标检测”打通形成一条可重复执行的流水线。1.2 技术方案拆解与选型原因整个系统拆成三块来看影像获取层解决“按经纬度范围自动采集影像”的问题。这里考虑了三种路径后面专门用一章详细对比。目标检测层用YOLOv9训练烟囱检测模型。选YOLOv9而不是更早的v5/v8主要是v9在特征提取网络和梯度信息保留上做了改进对遥感影像里这种目标小、背景纹理复杂、形状相对规整的检测场景表现更稳。工程落地层把模型封装成批量扫描脚本支持自定义区域、输出带坐标的检测结果并能叠加到地图上核验。开发语言选了Python原因很直接地图影像拉取、图像处理、深度学习训练、后端服务全链条都有成熟的Python生态GDAL/OpenCV/PyTorch/GEE SDK这一套下来不需要跨语言拼装一个人就能维护整条流水线。2. 环境准备Python与地图服务认证那些事2.1 依赖清单与安装顺序建议直接用Python 3.10或3.11版本太老或太新的版本有些依赖容易出兼容性麻烦。核心依赖如下torch2.0 torchvision0.15 opencv-python4.8 numpy1.24 pillow9.5 pyproj3.5 requests2.31 labelme5.4 pandas2.0 tqdm4.65安装顺序有讲究。先把PyTorch装好再装其他库因为PyTorch的CUDA版本决定了torchvision的版本范围如果先装了一堆依赖再装torch可能出现torchvision版本不匹配、import直接报错的情况。# 先装PyTorch按自己的CUDA版本选命令 pip install torch torchvision --index-url https://download.pytorch.org/whl/cu121 # 再装其他图像处理和GIS相关库 pip install opencv-python numpy pyproj requests labelme pandas tqdm注意GDAL这个库如果能pip install gdal直接装上就省事装不上就换conda install -c conda-forge gdal不建议在Windows上折腾源码编译浪费时间。2.2 地图影像服务的账号与访问凭证准备自动获取地图影像需要有合法的访问途径。无论你选哪家地图服务商正常流程都是去对应的开发者平台注册一个账号创建应用拿到API Key或者服务账号凭据然后按官方文档的接口规范去调用。Google Earth EngineGEE的话还需要在Cloud Console里创建项目并启用相关API然后用earthengine authenticate命令完成本地授权认证。这一步不复杂但要注意授权时选对账号和项目不然后面调用影像数据集时会提示权限不足。2.3 工程目录结构规划项目前期就把目录结构规划好能避免后面数据越堆越乱chimney_detection/ ├── data/ │ ├── raw_images/ # 拉取回来的原始影像 │ ├── sliced_images/ # 切分后的训练图片 │ ├── labels/ # 标注文件 │ └── dataset.yaml # YOLO训练数据配置 ├── scripts/ │ ├── fetch_gee.py # GEE影像拉取 │ ├── fetch_staticmap.py # 静态地图瓦片拉取 │ ├── slice_and_label.py # 切片与标注整理 │ ├── train.py # 训练入口YOLOv9官方代码 │ └── detect_batch.py # 批量检测与坐标回算 ├── models/ │ └── best.pt # 训练好的权重 └── outputs/ ├── predictions/ # 检测结果图 └── results.csv # 结构化结果表数据目录、脚本目录、模型目录、输出目录分开不混在一起。特别是raw_images和sliced_images必须分开因为原始影像要保留一份不可覆盖切出来的图可以随时删除重建标注文件和图片的对应关系才会清晰。3. 自动获取Google Earth图像三条路径的实战对比这一章是整个项目的地基也是我花时间最多的地方。这里直接给出我实测过的三条路径以及每条的代码逻辑和适用场景。3.1 路径一基于遥感影像数据集的程序化拉取Google Earth EngineGEE上维护了大量公开遥感数据集Landsat系列、Sentinel系列都有而且按卫星、按时间、按云量过滤都很方便。走这条路拿到的影像来源清晰适合做定量分析。import ee # 初始化GEE需要提前完成授权 ee.Initialize(projectyour-project-id) # 定义目标区域经纬度范围 roi ee.Geometry.Rectangle([116.0, 39.5, 117.0, 40.5]) # 筛选Sentinel-2影像取云量低、时间最近的一景 collection (ee.ImageCollection(COPERNICUS/S2_SR) .filterBounds(roi) .filterDate(2024-01-01, 2024-12-31) .filter(ee.Filter.lt(CLOUDY_PIXEL_PERCENTAGE, 20)) .sort(CLOUDY_PIXEL_PERCENTAGE)) image collection.first() if image: # 导出到Google Drive分辨率设为10米 task ee.batch.Export.image.toDrive( imageimage, descriptionsentinel2_export, folderearth_engine_exports, regionroi, scale10, crsEPSG:4326, fileFormatGeoTIFF, maxPixels1e10 ) task.start()这段代码的关键在于scale参数10米是指每个像素对应的地面范围。GEE导出是异步任务提交后需要轮询任务状态任务跑到云端去本地脚本只管提交和查询结果。import time task_id task.id while True: status ee.data.getTaskStatus(task_id)[0] state status[state] if state in [COMPLETED, FAILED]: print(state, status.get(error_message, )) break time.sleep(30)等任务完成后从Google Drive把GeoTIFF下载到本地再在GDAL或OpenCV里切块即可。这个路径适合需要保证分辨率统一、数据源可回溯的场景。3.2 路径二静态地图瓦片接口的网格化采样路径一适合大范围、低分辨率普查但如果你要的是一张能看清烟囱顶部结构的影像通常需要更高分辨率的数据。这时可以走地图服务商的静态地图接口按经纬度网格生成指定缩放级别下的卫星影像。import requests import math import os def lat_lng_to_pixel_center(lat, lng, zoom): # Web Mercator投影下把经纬度转为该缩放级别下的像素坐标 lat_rad math.radians(lat) n 2.0 ** zoom x (lng 180.0) / 360.0 * n y (1.0 - math.asinh(math.tan(lat_rad)) / math.pi) / 2.0 * n return int(x * 256), int(y * 256) def fetch_tile_center(api_key, lat, lng, zoom18, size640x640, save_dirraw_images): url https://maps.googleapis.com/maps/api/staticmap params { center: f{lat},{lng}, zoom: str(zoom), size: size, maptype: satellite, key: api_key } resp requests.get(url, paramsparams, timeout30) if resp.status_code 200: fname os.path.join(save_dir, f{lat:.6f}_{lng:.6f}_z{zoom}.png) with open(fname, wb) as f: f.write(resp.content) return fname return None # 按步长生成网格 min_lat, max_lat 39.5, 40.5 min_lng, max_lng 116.0, 117.0 step 0.002 # 经度步长 count 0 lat min_lat while lat max_lat: lng min_lng while lng max_lng: try: fname fetch_tile_center(your_api_key, lat, lng, zoom18) if fname: count 1 except Exception as e: print(ffailed at {lat}, {lng}: {e}) lng step lat step print(ftotal fetched: {count})这里有一个容易踩坑的点静态地图接口对单个Key的调用配额有限制不加间隔地快速请求很容易触发限流报错。我的做法是在循环里加time.sleep(0.5)实测把请求频率控制下来后批量任务基本不会中断。3.3 路径三Google Earth Pro手动框选导出如果目标区域不大不需要大规模自动化另一个稳妥的办法是用Google Earth Pro桌面端在软件里定位到感兴趣区域直接“保存图像”再通过脚本统一按经纬度命名。这个路径最大的问题是没有程序化的批量接口只能一个人对着屏幕手动框选适合样本探索和小规模数据验证不适合做训练数据生产。但它在模型验证阶段有一个独特价值可以快速获取特定目标的高清影像帮助人工确认模型检测结果的准确性。3.4 三条路径的横向对比对比维度GEE遥感数据集静态地图瓦片手动框选导出分辨率10米/30米最高可达0.5米级取决于视图高度批量能力强任务可并发强但受配额限制弱纯人工数据一致性高统一投影和分辨率中不同缩放级别有差异低每次框选范围不固定定量分析适合一般不适合自动化程度高高低我的建议是检测模型训练数据优先用静态地图瓦片或手动导出的高分辨率影像因为烟囱在10米分辨率的影像上通常只有几个像素大小训练出来的模型很难有效学习到烟囱的结构特征。而做区域普查、时序分析时用GEE数据更合适因为数据来源统一、可批处理、可追溯。4. 烟囱数据集的制作切片、标注与增广数据是目标检测项目的上限模型只是尽可能逼近这个上限。我把这个项目的训练数据处理完整记录下来。4.1 烟囱在影像上到底长什么样要做出有效的标注你得先理解烟囱在高分辨率遥感影像上的多尺度特征大型电厂烟囱高度几十米上百米在卫星影像上呈规则的圆形或椭圆形阴影顶部有时能看到白色的排放物痕迹底部常连接机房或烟道。中型工业烟囱类似但尺度更小在17~19级缩放级别下大约30~60个像素有明显长条形状。小型烟囱像素只有十几个容易和建筑物的通风格栅、塔吊混淆这是误检的主要来源。烟囱在二维影像上没有“宽度渐变”这种立体信息学习的关键是依靠“圆柱状结构顶部排放口与周边建筑高度差产生的阴影”等综合特征。所以训练数据里要尽量覆盖不同形状、不同材质、不同背景的烟囱。4.2 切片尺寸怎么定获取到的原始影像通常是一整张大地图不能直接塞给YOLO训练。需要切块切块尺寸640×640和YOLOv9默认输入尺寸一致省去推理时多余的resize。切块重叠率20%~30%。重叠的目的是防止烟囱正好被切在两张图边界上导致目标被截断。过滤空图如果某张切片里没有任何目标不要无脑全删保留一部分作为负样本用于训练。import cv2 import os def slice_image(img_path, save_dir, tile_size640, overlap0.25): img cv2.imread(img_path) if img is None: return h, w img.shape[:2] step int(tile_size * (1 - overlap)) count 0 for y in range(0, h - tile_size 1, step): for x in range(0, w - tile_size 1, step): tile img[y:ytile_size, x:xtile_size] fname f{os.path.basename(img_path).split(.)[0]}_{y}_{x}.jpg cv2.imwrite(os.path.join(save_dir, fname), tile, [cv2.IMWRITE_JPEG_QUALITY, 95]) count 1 return count4.3 标注规范和工具标注工具我用的是Labelme安装简单格式是JSON后面自己写脚本转成YOLO格式。import json import os def convert_labelme_to_yolo(json_path, save_dir, img_w, img_h): with open(json_path, r, encodingutf-8) as f: data json.load(f) yolo_lines [] for shape in data[shapes]: label shape[label] if label ! chimney: continue points shape[points] xs [p[0] for p in points] ys [p[1] for p in points] x_min, x_max min(xs), max(xs) y_min, y_max min(ys), max(ys) # 转YOLO格式中心点坐标和宽高均归一化 x_center (x_min x_max) / 2 / img_w y_center (y_min y_max) / 2 / img_h box_w (x_max - x_min) / img_w box_h (y_max - y_min) / img_h yolo_lines.append(f0 {x_center:.6f} {y_center:.6f} {box_w:.6f} {box_h:.6f}) txt_name os.path.basename(json_path).replace(.json, .txt) with open(os.path.join(save_dir, txt_name), w) as f: f.write(\n.join(yolo_lines))标注时有几个经验烟囱的检测框不要贴着目标最外缘要留出10%左右的边距。因为模型要学的是目标在局部区域的整体特征框太紧反而让特征信息不完整。被树木、建筑部分遮挡的烟囱也要标。模型在实际预测时大概率会遇到遮挡场景训练集里没有遮挡样本验证集成绩好看一到真实场景就崩。模糊的、只有几个像素的疑似目标建议要么不标要么单独建一个“弱目标”类别。我试过把模糊目标都归为“烟囱”结果模型把很多树冠上的高光点都当作烟囱。4.4 数据增强策略遥感影像和普通自然影像的增强侧重点不太一样。自然影像常用的水平翻转、随机裁剪在遥感影像里基本同理但有两个增强手段在遥感场景非常有效随机旋转90度、180度、270度遥感影像是俯视图旋转不改变目标物理含义能让模型学到不同方向下的目标形态。亮度对比度扰动不同时相、不同天气下影像亮度差异很大增强亮度可以让模型对光照条件更鲁棒。我的训练集规模是正样本约800张、负样本约1000张。负样本不是随机图片而是实际区域内没有烟囱的影像切片这一步是为了降低虚警率非常重要。模型训练完在验证集上mAP50大概在0.87左右虚警率控制在每平方公里1.5个以内算是平衡了召回率和精确率。5. YOLOv9训练把模型调到能用的状态5.1 准备自己的数据配置YOLOv9训练需要一个数据集配置文件指向训练集、验证集的图片路径和类别数。# data/dataset.yaml train: ./data/sliced_images/train val: ./data/sliced_images/val nc: 1 names: [chimney]图片目录结构如下YOLO代码会自动识别图片和同名的txt标注文件sliced_images/ ├── train/ │ ├── img_001.jpg │ ├── img_001.txt │ └── ... └── val/ ├── img_050.jpg └── img_050.txt5.2 训练命令与超参调整YOLOv9官方仓库拉下来后训练命令大概长这样python train.py \ --data data/dataset.yaml \ --weights yolov9-c.pt \ --img 640 \ --batch-size 16 \ --epochs 300 \ --device 0 \ --cache几个参数选择的经验--batch-size显存16G的话单卡batch-size16配合img640比较稳再大容易爆显存。如果显存小优先降batch而不是降分辨率分辨率对遥感小目标检测的影响非常直接。--epochs不是越多越好。烟囱类目标相对规整模型通常在150轮左右就能收敛到不错的效果后面基本在过拟合的边缘徘徊。我最终用early stopping在240轮自动停了。--weights从yolov9-c.pt预训练权重开始收敛速度和最终精度都比随机初始化好很多哪怕你的数据和COCO完全不同底层特征提取器的初始化依然有效。5.3 训练过程的loss曲线怎么看YOLOv9训练时输出一堆loss指标不需要全盯着看重点看box_loss和cls_loss这两个分别代表边框回归损失和分类损失。正常收敛的特征前50轮loss快速下降100轮后缓慢降低并小幅震荡。如果验证集loss持续不降反升就是典型过拟合此时可以尝试加--dropout或者用更大的训练集。如果前10轮loss就降到几乎为0很可能是标注数据有严重问题比如类别标错、txt文件对应不上要先检查数据再调参不要盲调。5.4 我的最终效果评估验证集上的评估我除了看mAP指标还会单独抽查模型在几种困难场景下的表现场景检测结果晴天、阴影清晰检测稳定置信度0.85多云、亮度偏低部分检出置信度下降至0.6~0.7小型烟囱像素20漏检率偏高约30%塔吊、通信塔等高耸目标存在少量误报这个结果说明模型在常规场景下可直接使用但在小目标和类烟囱目标上仍有优化空间。6. 识别检测系统的工程化落地细节训练好模型只是第一步能把模型变成可用工具才是完整的系统。6.1 模型导出训练完成后YOLOv9权重有两种常用导出方式TorchScript和ONNX。TorchScript可以直接被Python调用ONNX可以配合ONNX Runtime实现CPU上的高效推理。python export.py --weights runs/train/exp/weights/best.pt --include onnx导出ONNX后用ONNX Runtime做推理会比直接用PyTorch的torch.load加载再推理省下不少显存和耗时。实测在单张T4 GPU上640×640单图推理时间在25ms左右CPU上用ONNX Runtime大概150ms也能接受。6.2 批量检测与坐标回算系统级批量检测最核心的逻辑是检测结果返回的是像素坐标要把像素坐标换算回经纬度。这一步依赖前面拉图时的定位参数。import math def pixel_to_lat_lng(center_lat, center_lng, zoom, px_x, px_y, img_width640, img_height640): # 该缩放级别下整个世界的像素尺寸 world_px 256 * (2 ** zoom) # 中心点所在的世界像素坐标 center_merc_x (center_lng 180.0) / 360.0 * world_px lat_rad math.radians(center_lat) center_merc_y (1.0 - math.asinh(math.tan(lat_rad)) / math.pi) / 2.0 * world_px # 检测点相对中心点的像素偏移 dx px_x - img_width / 2.0 dy px_y - img_height / 2.0 # 目标的世界像素坐标 target_merc_x center_merc_x dx target_merc_y center_merc_y dy # 反算经纬度 lng target_merc_x / world_px * 360.0 - 180.0 n math.pi - 2.0 * math.pi * target_merc_y / world_px lat math.degrees(math.atan(math.sinh(n))) return lat, lng这一步不写对后面所有带坐标的结果都会偏移而且偏移量不是固定值是随缩放级别、纬度变化的排查起来非常痛苦。我在第7章会专门讲这个问题。6.3 结果落盘与可视化检测结果统一输出到CSV字段包括图片名、目标类别、置信度、目标中心的像素坐标、换算后的经纬度、图片中心经纬度。image_name,x_px,y_px,conf,lat,lng,img_center_lat,img_center_lng img_001.jpg,320,410,0.87,39.812345,116.456789,39.812000,116.456000CSV落盘之后后续可以用QGIS或地图服务商提供的可视化工具加载这个CSV做核验。再配合人工抽样检查确认坐标正确、目标无误后才算整条流水线闭环。7. 实测中的坑与排查链路没有踩过坑的项目不完整。这章把我在实际开发里遇到的三个影响最大的问题以及完整的排查链路写出来供大家参考。7.1 问题一下载的图像和标注框整体偏移了几十米现象我用静态地图瓦片拉取了一批区域影像标注时发现同一个烟囱在不同图片里位置“飘忽不定”有的图里目标在左上角有的在正中间。一开始以为标注没对齐后来把检测结果叠加到地图上发现所有检测点的实际地理位置都偏了大约60~80米。排查链路先怀疑是网格生成时经纬度步长算错了复查脚本没发现问题。再怀疑是拉图接口的center参数含义理解错了。检查后发现该接口传入的是“中心点经纬度”我用的是每个网格的左下角坐标导致每张图的中心点全部偏移了半个步长。这是第一层偏移修掉后偏移量从几百米降到了几十米。几十米误差依然存在继续查发现pixel_to_lat_lng里用的Web Mercator反算公式中math.asinh的参数用错了符号导致y方向的计算在高纬度时轻微偏移。最终修正两层问题后用GPS实测点对比误差控制在了15米以内。这个精度对烟囱这种大体量目标完全够用。经验之谈所有涉及坐标换算的代码写完第一件事不是跑通而是拿一个已知地物比如地图上某个标志建筑的经纬度做端到端验证输入图片、输出坐标和真实经纬度对比每一步单独测。7.2 问题二小目标大量漏检现象模型在训练集上表现很好但在一批真实新区域上对小型烟囱的召回率明显不足漏检率超过40%。排查链路先怀疑模型容量不够换更大的模型变体效果提升不明显。分析漏检目标的像素尺寸发现大部分漏检目标在640×640图片中只占不到20×20像素。这个尺寸对下采样32倍的YOLO来说目标在特征图上只有不到1个像素基本就是漏检的重灾区。解决思路改成切块前先放大影像再检测。把原始图像放大2倍再切片小型目标的像素尺寸翻倍有效缓解了漏检。代价是推理耗时增加约40%但对于离线批量任务完全可接受。这个问题的本质是输入分辨率与目标尺寸的匹配问题单纯调模型结构是治标不治本从数据流入手效率更高。7.3 问题三批处理任务动不动就中断现象拉取影像的任务跑到三分之一突然报异常退出。网络抖动、接口限流、磁盘空间不足什么都可能中断任务。排查链路原本以为是个技术难题实际是一个工程习惯问题——不要用一个裸循环跑完全部任务要把任务改成可断点续传的流水线。做法是每处理一个网格点就记录一条日志到CSV重新启动时先读取已完成的记录跳过已处理的点只处理剩余部分。import os import pandas as pd def load_done_set(log_path): if not os.path.exists(log_path): return set() df pd.read_csv(log_path) return set(df[index].tolist()) done load_done_set(progress.csv) for idx, (lat, lng) in enumerate(grid_points): if idx in done: continue try: fname fetch_tile_center(api_key, lat, lng) with open(progress.csv, a) as f: f.write(f{idx},{lat:.6f},{lng:.6f},{fname}\n) except Exception as e: with open(error.log, a) as f: f.write(f{idx},{lat},{lng},{e}\n)这个改动让任务中断后重跑的成本从小时级降到了分钟级可以说是整个系统里性价比最高的一项优化。8. 几个值得继续扩展的方向系统做到现在这个程度已经能稳定完成“给定区域范围→自动拉图→自动检测→输出带坐标的烟囱列表”的完整流程。但我在实际使用中也清楚它的局限以下几个方向如果条件允许很值得继续深挖。一是接入更高分辨率的商业化影像源目前用的免费静态瓦片在部分偏远地区清晰度不够小型烟囱几乎无法识别。商用影像源能把这个短板补上但需要评估成本。二是模型的“时序分析”能力。单时相的检测只能告诉你“哪里有烟囱”如果能把同一区域不同月份的影像都拉下来做时序对比可以进一步判断哪些烟囱在持续工作、哪些已经废弃这对环保监管是非常有价值的增量信息。三是把模型轻量化尝试用TensorRT或者OpenVINO做推理加速或者直接上边缘设备。现在T4 GPU上跑批量任务很轻松但如果要做实时视频流的烟囱检测工程上还需要进一步优化。最后分享一个我这套系统在实践里总结的经验机器学习模型本身只占整个项目三分之一的精力另外三分之二都在和数据打交道。影像怎么拉、拉完怎么保证坐标正确、切片怎么切、切完怎么标这中间任何一环出问题最后模型效果都会打折。先把数据的链路理顺模型训练反而是一件比较轻松的事情。