先说清楚一个事:这个项目做的不是“给一张图让模型猜有没有烟囱”的玩具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版本,太老或太新的版本有些依赖容易出兼容性麻烦。核心依赖如下:
torch>=2.0 torchvision>=0.15 opencv-python>=4.8 numpy>=1.24 pillow>=9.5 pyproj>=3.5 requests>=2.31 labelme>=5.4 pandas>=2.0 tqdm>=4.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 Engine(GEE)的话,还需要在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 Engine(GEE)上维护了大量公开遥感数据集,Landsat系列、Sentinel系列都有,而且按卫星、按时间、按云量过滤都很方便。走这条路拿到的影像来源清晰,适合做定量分析。
import ee # 初始化GEE,需要提前完成授权 ee.Initialize(project='your-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( image=image, description='sentinel2_export', folder='earth_engine_exports', region=roi, scale=10, crs='EPSG:4326', fileFormat='GeoTIFF', maxPixels=1e10 ) 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, zoom=18, size='640x640', save_dir='raw_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, params=params, timeout=30) 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, zoom=18) if fname: count += 1 except Exception as e: print(f'failed at {lat}, {lng}: {e}') lng += step lat += step print(f'total 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_size=640, overlap=0.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:y+tile_size, x:x+tile_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', encoding='utf-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(f'0 {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-size=16配合img=640比较稳,再大容易爆显存。如果显存小,优先降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_width=640, img_height=640): # 该缩放级别下整个世界的像素尺寸 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上跑批量任务很轻松,但如果要做实时视频流的烟囱检测,工程上还需要进一步优化。
最后分享一个我这套系统在实践里总结的经验:机器学习模型本身只占整个项目三分之一的精力,另外三分之二都在和数据打交道。影像怎么拉、拉完怎么保证坐标正确、切片怎么切、切完怎么标,这中间任何一环出问题,最后模型效果都会打折。先把数据的链路理顺,模型训练反而是一件比较轻松的事情。