简介:这份资源是「中国软件杯」A4赛题的完整项目源码包,基于百度飞桨(PaddlePaddle)构建遥感图像解译平台,面向参加软件杯赛事的高校学生、人工智能方向初学者及需要课程设计或毕业设计素材的开发者。项目涵盖前端、后端与深度学习模型推理等模块,可用于快速理解遥感图像解译任务的工程落地流程,也适合在现有代码基础上修改复刻、扩展功能。压缩包共130个文件,以49个py源码文件和78个pyc编译文件为主,另含少量md说明、txt配置与gitignore文件,整体约88KB,体量轻便,便于本地部署与阅读。目录中可见views、predictors、settings、models及数据库迁移脚本等模块,能帮助读者梳理从数据预测到服务接口的调用链路。目前已有205人学习下载,适合作为赛题复现、工程实训与二次开发的参考起点。
1. 遥感图像解译平台:从赛题到能跑通的工程链路
遥感图像解译这件事,放在赛题里往往被压缩成一句话:给一张卫星或航拍图,输出地物分类、目标检测或变化检测的结果。但真动手做「基于百度 PaddlePaddle 的遥感图像解译平台」,你会发现难点不在模型本身,而在数据怎么组织、推理怎么封装、前后端怎么串起来。中国软件杯这类赛题通常要求提交一个可运行的系统,而不是一个 notebook,所以「平台」两个字才是核心——它意味着你要把 PaddlePaddle 的推理能力包成服务,再配一个能上传图片、展示解译结果的前端。这篇文章面向准备做这个赛题、或者想搭一套遥感解译 demo 的工程师,从数据准备讲到服务封装和踩坑,每一步都给出可复现的命令和参数。
2. 遥感数据怎么进 PaddlePaddle:从原始影像到可训练张量
遥感图像和普通图像最大的区别在于尺寸和通道。一张高分卫星图动辄几千乘几千像素,直接 resize 到 224 会丢掉小目标;通道上又常见 RGB、多光谱甚至 SAR 的单通道浮点数据。PaddlePaddle 的paddle.io.Dataset和paddle.vision.transforms能覆盖大部分需求,但滑窗切图和归一化这两步必须自己写清楚。
2.1 滑窗切图与标签对齐的脚本写法
遥感解译里最常见的做法是先切图再训练。切图不是简单裁剪,要保证训练集和验证集之间没有重叠区域,否则验证指标会虚高。下面这个脚本按固定步长切图,同时把标签图做同样切分。
import os import numpy as np from PIL import Image def slide_crop(img_path, label_path, out_dir, crop_size=512, stride=256): """ 对遥感影像和标签做同步滑窗切图。 crop_size: 切图边长,常见 512 或 1024 stride: 滑动步长,一般取 crop_size 的一半,保证边缘目标不被截断 """ img = np.array(Image.open(img_path)) label = np.array(Image.open(label_path)) h, w = img.shape[:2] idx = 0 for y in range(0, h - crop_size + 1, stride): for x in range(0, w - crop_size + 1, stride): img_crop = img[y:y+crop_size, x:x+crop_size] label_crop = label[y:y+crop_size, x:x+crop_size] # 过滤掉标签全为背景的图块,减少无效样本 if label_crop.max() == 0: continue Image.fromarray(img_crop).save( os.path.join(out_dir, 'img', f'{idx:06d}.png')) Image.fromarray(label_crop).save( os.path.join(out_dir, 'label', f'{idx:06d}.png')) idx += 1 return idx逻辑说明:crop_size决定单次推理的感受野,512 在 8GB 显存上比较稳;stride取一半是为了让相邻图块有重叠,推理时再拼接回去,避免边界出现拼接缝。过滤全背景图块这一步很关键,遥感数据里背景占比经常超过 70%,不过滤会导致正负样本严重失衡。参数上,如果你做的是建筑物提取,crop_size可以降到 256,因为建筑物尺度小;做水体或林地分类,512 甚至 1024 更合适。
2.2 多光谱通道的归一化与 Dataset 封装
多光谱数据不能直接套 ImageNet 的均值和方差。常见做法是按波段统计均值和标准差,或者简单除以 255 再减 0.5。下面用paddle.io.Dataset封装一个可迭代的数据集。
import paddle from paddle.io import Dataset import cv2 import numpy as np class RemoteSensingDataset(Dataset): def __init__(self, img_dir, label_dir, transform=None): self.img_list = sorted(os.listdir(img_dir)) self.img_dir = img_dir self.label_dir = label_dir self.transform = transform def __getitem__(self, idx): name = self.img_list[idx] img = cv2.imread(os.path.join(self.img_dir, name), cv2.IMREAD_UNCHANGED) label = cv2.imread(os.path.join(self.label_dir, name), cv2.IMREAD_GRAYSCALE) # 多光谱归一化:按 16bit 转 8bit 再归一化,避免数值溢出 if img.dtype == np.uint16: img = (img / 256).astype(np.uint8) img = img.astype('float32') / 255.0 img = (img - 0.5) / 0.5 img = img.transpose(2, 0, 1) # HWC -> CHW label = label.astype('int64') return paddle.to_tensor(img), paddle.to_tensor(label) def __len__(self): return len(self.img_list)逻辑说明:IMREAD_UNCHANGED保证 16bit 影像不被截断;除以 256 再归一化是遥感里常用的降位深做法,比直接除以 65535 更稳定。transpose把通道提前,因为 PaddlePaddle 的卷积默认输入是 NCHW。标签用int64是因为交叉熵损失要求类别索引为整型。如果你的数据是单通道 SAR,把transpose那行去掉,并在模型第一层把in_channels改成 1。
3. 用 PaddlePaddle 搭解译模型:选型、训练与推理导出
遥感解译任务通常分三类:语义分割、目标检测、变化检测。赛题里最常见的是语义分割,因为标注成本相对低,评价指标也直观。PaddlePaddle 生态里可以直接用 PaddleSeg 或 PaddleDetection,但赛题往往要求你体现「自己搭」的过程,所以这里给一个轻量 U-Net 的实现和训练循环。
3.1 轻量 U-Net 的 PaddlePaddle 实现
import paddle import paddle.nn as nn class DoubleConv(nn.Layer): def __init__(self, in_ch, out_ch): super().__init__() self.conv = nn.Sequential( nn.Conv2D(in_ch, out_ch, 3, padding=1), nn.BatchNorm2D(out_ch), nn.ReLU(), nn.Conv2D(out_ch, out_ch, 3, padding=1), nn.BatchNorm2D(out_ch), nn.ReLU() ) def forward(self, x): return self.conv(x) class UNet(nn.Layer): def __init__(self, in_ch=3, num_classes=2): super().__init__() self.down1 = DoubleConv(in_ch, 64) self.down2 = DoubleConv(64, 128) self.pool = nn.MaxPool2D(2) self.up1 = nn.Conv2DTranspose(128, 64, 2, stride=2) self.up_conv1 = DoubleConv(128, 64) self.out = nn.Conv2D(64, num_classes, 1) def forward(self, x): d1 = self.down1(x) d2 = self.down2(self.pool(d1)) u1 = self.up1(d2) u1 = paddle.concat([u1, d1], axis=1) u1 = self.up_conv1(u1) return self.out(u1)逻辑说明:这是一个两层下采样的简化 U-Net,适合 512 尺寸输入和 2 到 5 类地物。DoubleConv里用 BatchNorm 加速收敛,遥感数据批次小的时候可以换成 GroupNorm。Conv2DTranspose做上采样,拼接时注意通道对齐。如果你的显存够,把通道数翻倍到 64/128/256 效果会更好,但训练时间也翻倍。
3.2 训练循环与学习率调度
model = UNet(in_ch=3, num_classes=2) optimizer = paddle.optimizer.Adam(learning_rate=1e-3, parameters=model.parameters()) scheduler = paddle.optimizer.lr.CosineAnnealingDecay(learning_rate=1e-3, T_max=50) loss_fn = nn.CrossEntropyLoss(ignore_index=255) for epoch in range(50): model.train() for img, label in train_loader: pred = model(img) loss = loss_fn(pred, label) loss.backward() optimizer.step() optimizer.clear_grad() scheduler.step() print(f'epoch {epoch}, loss {loss.numpy()[0]:.4f}')逻辑说明:ignore_index=255用来忽略标注里的无效区域,遥感标签经常有未标注像素。CosineAnnealingDecay的T_max设成总 epoch 数,让学习率平滑降到接近零。如果 loss 在前几个 epoch 不降,先检查标签是不是从 0 开始连续编码,很多翻车现场都是标签从 1 开始导致 CrossEntropy 越界。
3.3 推理导出与动态图转静态图
赛题提交通常要求能脱离训练环境跑推理,所以要把动态图模型导出成静态图。
model.eval() input_spec = paddle.static.InputSpec(shape=[None, 3, 512, 512], dtype='float32') paddle.jit.save(model, 'unet_infer', input_spec=[input_spec])逻辑说明:InputSpec的 batch 维度写None,这样导出后的模型支持任意 batch。paddle.jit.save会生成unet_infer.pdmodel和unet_infer.pdiparams两个文件,部署时用paddle.jit.load加载。注意导出前必须调model.eval(),否则 BatchNorm 会带着训练时的统计量,推理结果会飘。
4. 把模型包成解译平台:服务端与前端的最小闭环
「平台」意味着别人能通过界面上传图片、看到解译结果。最小闭环是 Flask 或 FastAPI 做后端,前端一个 HTML 页面加 canvas 展示。这里用 FastAPI,因为异步和文件上传写起来更干净。
4.1 FastAPI 推理接口与图像编码
from fastapi import FastAPI, File, UploadFile from fastapi.responses import JSONResponse import paddle import numpy as np import cv2 import base64 app = FastAPI() model = paddle.jit.load('unet_infer') model.eval() @app.post('/predict') async def predict(file: UploadFile = File(...)): contents = await file.read() img = cv2.imdecode(np.frombuffer(contents, np.uint8), cv2.IMREAD_COLOR) img = cv2.resize(img, (512, 512)) img = img.astype('float32') / 255.0 img = (img - 0.5) / 0.5 img = img.transpose(2, 0, 1)[None, ...] with paddle.no_grad(): pred = model(paddle.to_tensor(img)) mask = paddle.argmax(pred, axis=1).numpy()[0] # 把 mask 叠加成彩色图返回 color_mask = np.zeros((512, 512, 3), dtype=np.uint8) color_mask[mask == 1] = [0, 255, 0] _, buffer = cv2.imencode('.png', color_mask) b64 = base64.b64encode(buffer).decode() return JSONResponse({'mask': b64})逻辑说明:cv2.imdecode直接从字节流解码,省去落盘。paddle.no_grad()关闭梯度,推理速度能快 20% 左右。返回 base64 是为了前端直接塞进img标签的src,不用再起静态文件服务。参数上,resize到 512 是跟训练对齐,如果训练用了滑窗,这里也要改成滑窗推理再拼接。
4.2 前端上传与结果叠加
<!DOCTYPE html> <html> <body> <input type="file" id="upload" accept="image/*"> <canvas id="canvas" width="512" height="512"></canvas> <script> document.getElementById('upload').onchange = async (e) => { const file = e.target.files[0]; const form = new FormData(); form.append('file', file); const res = await fetch('/predict', {method: 'POST', body: form}); const data = await res.json(); const img = new Image(); img.onload = () => { const ctx = document.getElementById('canvas').getContext('2d'); ctx.drawImage(img, 0, 0); }; img.src = 'data:image/png;base64,' + data.mask; }; </script> </body> </html>逻辑说明:前端只做两件事——上传和展示。FormData直接传文件,后端返回的 base64 塞进Image对象再画到 canvas。如果你要叠加在原图上,可以再画一层半透明 mask,用globalAlpha控制透明度。这个闭环跑通之后,赛题要求的「平台」基本就成立了,剩下的就是加登录、历史记录这些外围功能。
5. 避坑与排查:遥感解译平台最常见的五个翻车点
5.1 推理结果全黑或全白
现象:上传图片后返回的 mask 要么全黑要么全白,没有任何地物轮廓。原因通常是归一化不一致——训练时用了(img - 0.5) / 0.5,推理时只做了/ 255。解决:把训练和推理的预处理写成一个函数,两边调用同一个实现,别复制粘贴。
5.2 显存溢出但 batch size 已经设为 1
现象:crop_size=1024时单张图就爆显存。原因是 U-Net 在浅层特征图尺寸大,BatchNorm 和中间激活占显存。解决:把crop_size降到 512,或者把模型通道数减半,再或者用paddle.amp开混合精度。混合精度在 PaddlePaddle 里一行scaler = paddle.amp.GradScaler()就能开,显存能省 30% 到 40%。
5.3 验证集指标高但实际效果差
现象:验证集 mIoU 到 0.85,但拿真实卫星图跑出来一塌糊涂。原因是切图时训练集和验证集有重叠区域,模型记住了验证集。解决:按地理区域划分,而不是随机划分图块。如果数据没有地理信息,至少保证验证集的图块在训练集里找不到重叠。
5.4 多光谱影像读取后通道数不对
现象:cv2.imread读 4 波段影像返回 3 通道,或者读 16bit 影像返回 8bit。原因是 OpenCV 默认做色彩空间转换。解决:用cv2.IMREAD_UNCHANGED,读进来后手动取需要的波段。如果波段数超过 4,OpenCV 支持有限,改用rasterio或tifffile读。
5.5 静态图导出后推理结果和动态图不一致
现象:动态图验证正常,paddle.jit.save之后推理结果全乱。原因通常是导出时没设eval(),或者InputSpec的 shape 和实际输入不匹配。解决:导出前打印一次model.training确认是 False;InputSpec的 H/W 必须和推理时一致,不一致的话在推理前 resize。
6. 进阶技巧:用滑窗拼接和 TTA 把解译精度再拉一截
滑窗推理是遥感解译里性价比最高的技巧。训练时切了图,推理时如果直接 resize 整图,小目标会丢;正确做法是按训练时的crop_size和stride滑窗推理,再把每个窗口的预测拼回原图。拼接时重叠区域取平均或投票,能消掉窗口边界的突变。
def slide_inference(model, img, crop_size=512, stride=256, num_classes=2): """ 滑窗推理并拼接,重叠区域用概率平均。 img: HWC float32 已归一化 """ h, w, c = img.shape prob_map = np.zeros((num_classes, h, w), dtype=np.float32) count_map = np.zeros((h, w), dtype=np.float32) for y in range(0, h - crop_size + 1, stride): for x in range(0, w - crop_size + 1, stride): crop = img[y:y+crop_size, x:x+crop_size] crop = crop.transpose(2, 0, 1)[None, ...] with paddle.no_grad(): pred = model(paddle.to_tensor(crop)) prob = paddle.nn.functional.softmax(pred, axis=1).numpy()[0] prob_map[:, y:y+crop_size, x:x+crop_size] += prob count_map[y:y+crop_size, x:x+crop_size] += 1 count_map[count_map == 0] = 1 prob_map /= count_map[None, ...] return np.argmax(prob_map, axis=0)逻辑说明:prob_map累加每个窗口的 softmax 概率,count_map记录每个像素被覆盖的次数,最后取平均。这样重叠区域的预测更平滑。stride越小,重叠越多,精度越高但推理越慢,一般取crop_size的一半是精度和速度的平衡点。
TTA(测试时增强)是另一个能稳定涨点的技巧。对同一张图做水平翻转、垂直翻转、旋转 90 度,分别推理后再把结果翻转回来取平均。遥感图像地物通常具有旋转不变性,TTA 一般能涨 1 到 3 个点。代价是推理时间翻四倍,赛题如果有时限要求,建议只做水平翻转这一种。
我自己的习惯是:训练完先跑一遍滑窗推理看拼接缝,如果缝明显就把stride再调小;然后加水平翻转 TTA 对比 mIoU,涨点不到 0.5 就不加,因为部署复杂度上去了。这套流程在几次遥感解译任务里都稳,希望帮到你。
本文还有配套的精品资源,点击获取