简介这是中国软件杯A4赛题中基于百度飞桨PaddlePaddle实现遥感图像解译的完整项目包面向软件杯参赛者、毕业设计/课程设计学生以及想用飞桨投入遥感图像分析与识别实践的入门与进阶学习者。压缩包共含130个文件其中49个Python源码对应Django框架下的设置、视图、预测器与模型定义等核心逻辑78个pyc是编译过程生成的中间文件另有说明文档、Markdown笔记与版本忽略配置整体仅88KB结构清晰、部署轻量。目前已有205人浏览学习适合直接对照赛题复现流程。项目保留了视图、模型、预测器及数据库迁移等关键模块并覆盖从请求处理、模型加载到结果输出的主要链路读者既可在其上理解遥感解译平台的前后端交互与处理逻辑也能替换数据、调整算法或扩展更多识别功能作为工程实训或初期项目立项的起点。1. 中国软件杯A4赛题为什么难在“平台”而不是“模型”拿到中国软件杯A4赛题的这份基于百度paddlepaddle的遥感图像解译平台项目包多数人的第一反应是去翻训练脚本但我建议你先看清这份平台包解决了什么问题。遥感图像解译不是单点模型任务它至少包含影像读取与切块、样本均衡、模型训练、结果拼接、导出部署五个环节任何一个环节断掉模型在测试集上再准也交付不了。这个赛题和真实遥感项目的差距恰好就藏在“平台”这两个字里。这篇笔记适合两类人正在备赛、需要快速把流程跑通的队伍以及想用PaddlePaddle做遥感落地的开发者。我会按照从数据到部署的顺序把每个环节的关键参数和踩过的坑讲清楚。2. 遥感图像解译平台的地基数据切块与预处理方案2.1 遥感影像先说清楚多波段、大尺寸与标注格式遥感图像解译平台的第一步不是写模型而是理解数据本身。赛题提供的遥感影像通常是GeoTIFF格式与普通照片相比有三个显著差异第一是波段数常见影像包含RGB和近红外四个波段甚至更多模型输入的通道数要随波段调整第二是位深很多影像不是8bit而是16bit甚至更高直接用img / 255会得到一片黑第三是尺寸一张原始影像动辄上万乘上万的像素显存根本放不下必须先切块。标注格式也需要提前确认。赛题常见的是单波段PNG掩码每个像素值代表一个类别背景为0。如果给的是GeoJSON或ShapeFile矢量标注你还需要额外写一套栅格化脚本把矢量转成逐像素的掩码。很多队伍在这里消耗掉大量时间因为标注格式转错会导致训练集里全是背景。我的建议是先打印标注类别分布确认每个类别的像素占比再继续。import numpy as np from collections import Counter mask np.load(train/masks/area_01.npy) counter Counter(mask.flatten()) print(counter) # 输出示例: Counter({0: 998811, 1: 8452, 2: 2152, 3: 198})这段代码用来检查每个类别的像素数量。如果类别分布严重倾斜背景类占99%以上那么损失函数和评估指标都要做相应调整否则模型会倾向于把所有像素都预测为背景。2.2 把一张万级像素大影像切成可训练的样本滑窗脚本切块是遥感训练流程里最核心的预处理操作切块参数直接决定模型能看到什么。常见做法是滑窗加重叠窗口大小常选512或256重叠区域选64到128像素。为什么需要重叠因为后续推理大图时也要用滑窗预测再对重叠区做融合如果训练时没有重叠推理时拼接边界容易出现明显的接缝。下面的脚本是我常用的切块方式读入GeoTIFF和掩码做归一化后按滑窗保存为npy文件。import rasterio import numpy as np from pathlib import Path def sliding_window_crop(image_path, mask_path, out_dir, patch_size512, overlap64): with rasterio.open(image_path) as src: img src.read().astype(np.float32) # (C, H, W) with rasterio.open(mask_path) as msk: mask msk.read(1).astype(np.int64) # (H, W) lo, hi np.percentile(img, (2, 98)) img np.clip((img - lo) / (hi - lo 1e-6), 0, 1) step patch_size - overlap idx 0 for y in range(0, mask.shape[0], step): for x in range(0, mask.shape[1], step): img_patch img[:, y:y patch_size, x:x patch_size] mask_patch mask[y:y patch_size, x:x patch_size] if img_patch.shape[1] ! patch_size or img_patch.shape[2] ! patch_size: pad_h patch_size - img_patch.shape[1] pad_w patch_size - img_patch.shape[2] img_patch np.pad(img_patch, ((0, 0), (0, pad_h), (0, pad_w))) mask_patch np.pad(mask_patch, ((0, pad_h), (0, pad_w))) np.save(out_dir / fimg_{idx}.npy, img_patch) np.save(out_dir / fmask_{idx}.npy, mask_patch) idx 1参数说明patch_size512时模型输入尺寸是512x512显存占用适中step patch_size - overlapoverlap64表示相邻两个窗口有64像素的重叠区域能缓解预测接缝问题。归一化使用2%和98%的百分比截断主要应对16bit影像的动态范围。保存为npy而不是PNG是因为npy能保留浮点精度的归一化结果避免PNG的8bit量化损失。有一个细节容易忽略切块后要筛选掉纯背景块不然训练集里全是背景会让模型学不到地物特征常见做法是按mask中非零像素占比过滤。2.3 预处理与增强归一化是精度分水岭图像预处理里最影响模型精度的就是归一化方式。遥感影像的动态范围跨度很大一景影像内可能同时包含阴影、水体、建筑和裸土如果用全局均值和方差做标准化高亮的屋顶和阴影区域都会丢失细节。百分比截断比均值方差更稳波段值在2%到98%分位之间压缩到0到1异常值不会撑爆整个动态范围。如果影像包含近红外波段建议保留它而不是只取RGB植被相关类别在近红外波段上区分度非常高。数据增强方面遥感影像和自然图像有个重要区别不需要随便做色彩抖动因为地物类别对色彩敏感把植被颜色调绿了可能被模型误判成草地。我一般在训练时使用水平垂直翻转、90度旋转和随机裁剪这类几何增强不会改变地物的光谱特征。如果做随机裁剪注意掩码要和影像同步变换用相同的随机种子或者同时变换。增强强度不宜过大遥感分割任务里增强的主要目的是增加空间变换不变性而不是让模型适应光照变化。import numpy as np def random_flip_rotate(img, mask): if np.random.rand() 0.5: img img[:, ::-1, :] mask mask[::-1, :] if np.random.rand() 0.5: img img[:, :, ::-1] mask mask[:, ::-1] k np.random.randint(0, 4) img np.rot90(img, k, axes(1, 2)) mask np.rot90(mask, k) return img.copy(), mask.copy()这个函数传入的img形状是(C, H, W)mask是(H, W)。注意反转和旋转都作用在最后两个维度掩码同步变换。增强后的掩码必须保持int64类型不能因为翻转产生插值这也是为什么翻转旋转比弹性形变更安全。训练时增强验证和推理时不做任何随机操作。3. 用PaddlePaddle训练遥感解译模型模型选型与训练参数3.1 语义分割选U-Net还是DeepLabV3从赛题精度与推理速度权衡遥感图像解译平台中语义分割是最常见的核心任务把每个像素分类为建筑、水体、道路、植被等类别。模型选型需要在精度和训练资源之间权衡。PaddleSeg里自带的U-Net和DeepLabV3是两套典型方案U-Net的编码器-解码器结构对小目标更友好参数量小训练速度快但感受野有限DeepLabV3使用空洞卷积扩大感受野对大面积连续地物边界更干净但显存占用更高训练时间也更长。针对A4赛题这种需要快速迭代的场景我一般先用DeepLabV3PPaddleSeg中的增强版DeepLabV3配合ResNet50_vd骨干网络跑通全流程精度稳定后再考虑换HRNet这类高精度模型。这里有一个很重要的认知不要一开始就上最重的模型先把数据流程和评估指标跑通然后再回来换模型。很多队伍第一天就训练DeepLabV3跑了两天才发现数据归一化有问题白白浪费算力。from paddleseg.models import DeepLabV3P from paddleseg.models.backbones import ResNet50_vd model DeepLabV3P( num_classes5, backboneResNet50_vd(pretrainedhttps://bj.bcebos.com/paddleseg/backbones/resnet50_vd_ssld.tar.gz), backbone_indices(3, ), aspp_ratios[1, 6, 12, 18], aspp_sep_convFalse, align_cornersTrue, )参数说明num_classes根据掩码类别数设定背景算一个类别本例为5。aspp_ratios是空洞卷积的扩张率默认的[1, 6, 12, 18]在遥感影像上不需要改动这个组合能覆盖不同尺度的地物。aspp_sep_conv设为False使用普通卷积而不是分离卷积训练速度略慢但精度略高。backbone的预训练权重用的是PaddleSeg官方提供的ImageNet权重能显著加速收敛。3.2 用PaddleSeg快速搭起训练脚本的三个关键对象训练遥感分割模型最难的部分不是定义网络本身而是把数据集、损失函数和评估指标正确接起来。PaddleSeg提供了Trainer封装但很多队伍在自定义数据集时总在标注格式上踩坑。下面是PaddleSeg自定义数据集的搭建方式核心是生成train.txt、val.txt每行写“图像路径 掩码路径”。import os from paddleseg.datasets import Dataset # 假设已经按 2.2 节的切块脚本生成若干 npy 文件 # 把 npy 转成 PaddleSeg 要求的 png 格式或直接写自定义 Dataset更通用的做法是自己写Dataset类继承paddle.io.Dataset完全掌控数据流。下面的示例展示如何把上一章切好的npy文件输入到模型中。import paddle import numpy as np from paddle.io import Dataset class RemoteSegDataset(Dataset): def __init__(self, img_dir, mask_dir, modetrain): super().__init__() self.img_paths sorted((img_dir / img_*.npy).glob(img_*.npy)) self.mask_paths sorted((mask_dir / mask_*.npy).glob(mask_*.npy)) self.mode mode def __getitem__(self, idx): img np.load(self.img_paths[idx]).astype(np.float32) mask np.load(self.mask_paths[idx]).astype(np.int64) if self.mode train: img, mask random_flip_rotate(img, mask) return paddle.to_tensor(img), paddle.to_tensor(mask) def __len__(self): return len(self.img_paths)在__getitem__中返回paddle.Tensor也可以返回numpy数组再由DataLoader转换两种方式都可以。注意掩码必须是int64形状是(H, W)而不是(1, H, W)PaddleSeg的损失函数会按NCL格式自动扩展维度。如果掩码里有255这类忽略标签值要确认损失函数是否支持ignore_index设置否则会干扰训练。3.3 训练参数里真正影响精度的三个开关learning rate、batch size和损失函数权重这三个参数决定了模型能否收敛以及收敛到什么水平。遥感分割常用多项式衰减学习率初始值0.01对于从头训练勉强够用但使用预训练backbone时0.01容易让参数剧烈震荡我通常设在0.005到0.01之间配合LearningRateDecay策略。损失函数方面纯交叉熵在类别不平衡时表现差。PaddleSeg提供了混合损失接口把交叉熵和Dice Loss按权重相加常见比例是CE权重1.0、Dice权重0.3。Dice Loss对类别不平衡不敏感能让模型在稀有类别上学到东西。import paddle.nn as nn from paddleseg.models.losses import CrossEntropyLoss, DiceLoss losses { type: [ {CrossEntropyLoss: {ignore_index: 255}}, {DiceLoss: {ignore_index: 255}}, ], coef: [1.0, 0.3], }参数说明ignore_index255表示标签中标记为255的像素不参与损失计算适用于标注中的不确定区域。coef是各损失的加权系数如果类别严重不均衡可以把DiceLoss的系数提高到0.5甚至更高但过高会导致训练早期梯度不稳定模型输出层概率分布被拉平。4. 模型导出与部署把训练产物变成可调用的服务4.1 动态图转静态图paddle.jit.save的正确姿势训练完成后模型的预测代码通常在动态图模式下运行每一层都按即时执行方式计算这适合调试但不适合部署。部署阶段需要把模型固化成静态图预测模型PaddlePaddle使用paddle.jit.save完成转换。转换是对模型结构的一次固化输入和输出的形状在转换时确定后续推理不再依赖训练代码。一个常见的错误是直接保存动态图模型然后在另一台机器上重新定义网络结构再加载权重。这样不仅笨重还容易出现版本不匹配问题。正确做法是使用InputSpec明确声明输入形状。import paddle # model 是训练好的动态图模型 model.eval() paddle.jit.save( model, output/remote_model, input_spec[ paddle.static.InputSpec( shape[1, 3, 512, 512], dtypefloat32, nameimage ) ], )参数说明shape的第一个维度1表示推理时的batch size。如果你的服务需要一次处理多张图可以把shape设为[None, 3, 512, 512]None表示batch维度可变。name字段给输入张量起名后续推理请求中参数名要与之一致。保存后output目录下会生成.pdmodel和.pdiparams两个文件前者描述计算图后者保存权重部署时要一起拷贝。4.2 推理端预处理要与训练端完全一致部署推理时最容易翻车的地方是预处理不一致。训练时做了百分比截断归一化推理时如果只做简单的除以255输入分布变了模型输出自然异常。这不是模型问题是数据流问题。我曾经接过一个项目训练好的模型在验证集上mIoU有0.75换到推理服务里预测结果全是噪点排查了两天发现推理端把16bit影像直接除以255而训练端是从多波段数据按百分比截断后的结果。推理端的预处理必须在服务代码里复现训练时的流程。通用的做法是把预处理逻辑封装成一个函数训练和推理共用一份代码避免两边各写各的。import numpy as np def preprocess(image_16bit): lo, hi np.percentile(image_16bit, (2, 98)) img np.clip((image_16bit - lo) / (hi - lo 1e-6), 0, 1) img img.astype(np.float32) return img如果训练脚本里同时包含翻转增强推理时不加如果训练时把影像缩放到指定尺寸推理时也要用同样的插值方式缩放。一句话训练时做了什么推理时原样复现一个参数都不能差。4.3 用Paddle Inference把模型跑起来并拼接结果模型固化后使用Paddle Inference执行推理。Paddle Inference是PaddlePaddle的官方推理引擎支持静态图模型的高效执行不需要再加载backbone等训练组件。推理阶段的核心是准备输入数据、执行预测、整理输出。import paddle.inference as paddle_infer config paddle_infer.Config(output/remote_model.pdmodel, output/remote_model.pdiparams) predictor paddle_infer.create_predictor(config) input_names predictor.get_input_names() input_tensor predictor.get_input_handle(input_names[0]) output_tensor predictor.get_output_handle(predictor.get_output_names()[0]) # image_batch 是形状为 (N, 3, H, W) 的 float32 数组 input_tensor.copy_from_cpu(image_batch) predictor.run() output output_tensor.copy_to_cpu() # (N, C, H, W) pred_labels output.argmax(axis1)代码说明Config传入之前保存好的.pdmodel和.pdiparams文件。create_predictor创建推理器copy_from_cpu把输入数据拷入显存或内存run执行预测copy_to_cpu取回结果。输出形状是(N, C, H, W)argmax在类别维度上取最大值的索引得到每个像素的类别标签。推理大图时用滑窗切块逐块预测再把预测结果按坐标拼回原始尺寸这里建议重叠区域用高斯权重融合而不是简单取平均能有效减少接缝痕迹。把预测服务封装成HTTP接口项目里用Flask就够轻量。关键是在服务启动时创建Predictor实例不要在每个请求里重复创建模型否则显存会急剧增长。批量推理时设置一个合理的batch size比如4或8吞吐量能提升不少。5. 遥感解译平台落地避坑5个让训练白费的典型问题5.1 转换ONNX失败failed to convert paddlepaddle model现象训练好模型后用paddle2onnx导出ONNX终端报错failed to convert paddlepaddle model: (unimplemented) the 0th elementwise mu。原因PaddlePaddle模型图中的elementwise_mul算子没有被当前版本的Paddle2ONNX转换器支持。这个算子出现在一些注意力机制或特征融合结构中较老版本的转换器识别不了。解决不要死磕ONNX格式。如果最终部署环境是Paddle Inference直接用4.1节的paddle.jit.save保存和加载就好完全绕开ONNX转换。如果必须用ONNX先检查paddle2onnx和PaddlePaddle版本是否匹配再考虑把模型结构里的自定义算子替换为更通用的卷积和加和操作。从赛题交付角度Paddle Inference本身就能满足本地推理和API服务需求没必要为格式转换浪费排查时间。5.2 显存OOM把整张大影像送进模型现象训练或推理时显存占用直接打满程序崩溃。排查发现是直接把原始大尺寸GeoTIFF做resize后输入模型。原因遥感影像原始尺寸可能有上万像素即使resize到2000x2000显存占用也远超常规。模型在512x512输入下正常但输入尺寸放大4倍中间特征图的显存开销是平方级增长。解决严格按2.2节滑窗方案处理训练时输入固定为512x512或256x256。推理时也用同尺寸滑窗预测后拼回原图。如果单张图切块数量太多可以增加batch中的图像块数量同时减少每一块的尺寸找一个显存和速度的平衡点。5.3 训练损失不降背景像素淹没了目标类现象损失值在初始值附近抖动不下降或者下降极慢模型预测结果几乎全是背景。原因遥感分割的标注图中背景占比极高前景类别像素占比低于1%很常见。交叉熵损失对所有像素一视同仁模型只要输出背景就能得到很小的损失梯度中前景信息被背景完全淹没。解决调整损失函数权重。把Dice Loss系数调高到0.5以上同时为不同类别设置分类权重稀缺类别权重增大。另外可以在数据预处理阶段过滤掉完全没有前景目标的切块减少无意义样本的比例。我常用的过滤阈值是前景像素占比低于5%的训练样本直接丢弃。5.4 预测结果出现明显接缝滑窗拼接没有处理重叠区现象大图预测结果中每隔固定距离出现一道明显的网格线尤其是边界处像素分类错误。原因切块推理时相邻窗口重叠区域内模型预测结果不完全一致拼接时简单取边缘一个窗口的结果导致重叠区与另一侧不一致形成接缝。解决拼接时对重叠区域做加权融合越靠近窗口中心权重越大。高斯权重矩阵能平滑过渡避免硬边界。也可以减少推理步长增加重叠像素数让模型在两个窗口中看到更多相同内容但推理时间会相应增加。5.5 16bit影像预处理错误预测图全是噪点现象验证集上mIoU很高但部署推理时预测结果一团乱码轮廓全成黑白噪点。检查发现输入影像像素值范围是0到65535却按0到255的方式归一化。原因16bit遥感影像的像素值范围大训练时用了百分比截断推理时又换了方式两边预处理不一致。解决把预处理函数单独提出来训练和推理共用同一份代码并且把归一化参数写入配置文件。更稳妥的做法是保存模型时把预处理参数一起保存推理服务加载模型的同时读取这些参数避免人工复制出错。6. 最后一道工序用mIoU和可视化图验证模型真的能用模型训练完不能只看训练损失曲线就提交。我习惯先做两件事计算逐类别的IoU和生成可视化对比图。mIoU相比loss更能反映分割质量尤其是类别不平衡时一个模型如果背景IoU很高但建筑IoU很低mIoU会直接暴露问题。Kappa系数也是遥感评估常用的指标衡量预测结果与真实标注的一致性比整体准确率更可靠。写一个脚本评估验证集输出每个类别的IoU、mIoU和Kappa。孔洞、粘连和边缘锯齿都是需要关注的如果可视化图上出现了不合理的碎块很可能需要形态学后处理或条件随机场平滑边界。评估脚本作为平台的固定组件保留下来每次迭代模型都要跑一遍多轮比较才能确定真正有效的改进。我习惯把每次实验的mIoU、时间、训练参数记录在一个表里方便回溯最优配置。希望这份从数据到部署的完整拆解能帮你把平台跑通少走一些我已经走过的弯路祝你在赛题和真实项目中都能顺利落地。本文还有配套的精品资源点击获取
