☰
遥感分割项目PaddleSeg到ONNX部署全流程实战指南
2026/10/21 12:54:20 网站建设 项目流程

我最近刚好把一个遥感分割项目从 PaddleSeg 一路跑通了 ONNX 部署,整个过程踩了不少坑,尤其是从 PaddlePaddle 的动态图模型转到静态图再导出 ONNX 的那一步,文档里写得很简略,实际做起来却有不少讲究。遥感影像和自然图像不一样,图幅大、地物尺度差异悬殊、类别分布极度不均衡,训练阶段和部署阶段都会遇到一些特有的问题。这篇文章就把我的完整思路和实操方法整理出来,从数据准备到模型训练,再到 ONNX 导出和部署验证,逐段讲清楚为什么要这么做,以及每一步具体怎么操作。

如果你手里正好有一批遥感影像数据要做分割,或者你已经在用 PaddleSeg 但卡在模型导出环节,这篇文章应该能帮你省下不少时间。我会把容易出错的地方单独拿出来讲,包括数据切块的尺寸选择、损失函数的搭配、训练超参的设置,以及 ONNX 导出时输入输出张量的处理细节。

1. 为什么遥感分割项目首选 PaddleSeg:选型逻辑与适用边界

先说结论:PaddleSeg 在遥感分割这件事上,确实比很多通用分割框架更顺手,但也不是没有前提条件。遥感影像分割和普通语义分割最大的区别在于数据形态和标注方式,PaddleSeg 对这两点的支持做得比较到位。

遥感影像通常是大幅面的 TIF 文件,尺寸动辄几千乘几千像素,而显卡显存根本放不下这么大一张图直接训练。所以第一步必须做切块处理,把大图切成 512×512 或 256×256 的小块。PaddleSeg 虽然没有内置专门的遥感切块工具,但它的 Dataset 接口支持自定义数据读取逻辑,你可以很方便地在__getitem__里实现滑窗裁剪、重叠采样、多尺度缩放等操作。这比很多框架把数据加载管线焊死的情况要灵活得多。

另外,PaddleSeg 的分割模型库非常全。以遥感场景最常用的几个模型为例,U-Net 适合小数据集快速迭代,DeepLabV3+ 在边界细节上表现不错,而 SegFormer 这类 Transformer 结构的模型在处理大尺度地物(比如大面积水体、连片农田)时优势更明显。PaddleSeg 把这些模型的训练配置都整理好了,你只需要改一下 config 文件里的数据集路径和类别数,就能直接开训。

还有一个很实际的原因:PaddleSeg 本身是基于 PaddlePaddle 的,它导出的模型可以通过 paddle2onnx 工具直接转成 ONNX 格式,整个链路是官方维护的,版本兼容性有保障。我自己试过 PyTorch 训练完再转 ONNX 的流程,也试过 PaddleSeg 的流程,后者的坑明显少很多,尤其是在动态 shape 处理和算子映射这两块,官方工具的打磨程度更高。

不过 PaddleSeg 也不是万能的。如果你的部署环境非常特殊,比如要在某些边缘设备上跑,而这些设备对 PaddlePaddle 算子的支持很有限,那么仍然建议你在导出 ONNX 之后,再用 ONNXRuntime 做一遍完整测试。另外,PaddleSeg 的 config 体系学习成本稍微高一点,第一次用的时候会有点懵,熟悉之后就会发现它的配置管理方式其实很清晰。

2. 遥感数据集的整理与预处理:最容易拉开项目差距的环节

很多人在遥感分割项目里只盯着模型结构和训练技巧,但其实数据预处理才是决定项目上限的关键。我见过不少效果不理想的模型,追根溯源都是数据环节出了问题。这一节把我会用到的处理流程完整讲一遍,每一步都有我实测过的具体理由。

2.1 标签制作与格式转换

遥感分割的标签通常是矢量格式,比如 Shapefile,而 PaddleSeg 直接读不了矢量。你需要在 GIS 软件里把矢量栅格化成与影像对齐的标签图,然后转成 PNG 格式。这里有个关键点:标签图的类别编号必须从 0 开始连续编码,0 通常表示背景类。PaddleSeg 在计算损失时会忽略某些类别(通过ignore_index设置),但如果你把背景编成 255,默认配置下会直接报错。

我整理的标签规范如下表所示:

类别含义像素值说明
背景0未标注区域
建筑1主要提取目标
植被2次要类别
道路3次要类别
ignore255边界模糊区域、标注质量差区域

注意ignore_index这个参数。遥感数据里总有一些边界区域是人工难以准确判断的,比如建筑物的阴影边缘、山林和草地的过渡带,这些区域的标签如果硬编码成某个类别,会严重干扰训练。我把它们统一编码为 255,训练时在损失函数里设置ignore_index=255,让模型直接跳过这些像素。实测下来这能让最终 mIoU 提高两到三个百分点,非常划算。

2.2 影像切块:尺寸、重叠与步长的取舍

切块是遥感数据预处理里最关键也最容易被低估的步骤。我的做法是把原始大图按滑动窗口切成小块,训练时用 512×512,步长设为 384,即相邻块之间有 128 像素的重叠。这样做的原因是:一个地物如果正好被切在图像边缘,模型可能只看到它的一半,导致推理时预测不稳定。重叠切块等于让边缘地物在另一块里处于完整状态,相当于一种隐式的数据增强。

推理阶段的切块和训练阶段不太一样。到了部署环节,通常你是拿整幅遥感图去推理的,这时候需要在 ONNX Runtime 里做一个滑窗预测后处理:每次输入 512×512 的块,推理完再拼接回去。拼接区域直接取预测结果可能会产生明显的块状接缝,我建议对重叠区域做加权融合,离块中心越近权重越高。下面是切块和拼接的核心逻辑,用伪代码体现思路:

def sliding_window_infer(model, big_image, window_size=512, stride=384, num_classes=4): h, w = big_image.shape[:2] score_map = np.zeros((h, w, num_classes), dtype=np.float32) weight_map = np.zeros((h, w, 1), dtype=np.float32) y_start = 0 while y_start < h: x_start = 0 while x_start < w: y_end = min(y_start + window_size, h) x_end = min(x_start + window_size, w) patch = big_image[y_start:y_end, x_start:x_end] pred = model(patch) # 输出形状 [1, num_classes, window_size, window_size] pred = pred[0].transpose(1, 2, 0) # 生成三角权重,中心为1,边缘为0 weight = get_triangular_weight(window_size) pred = pred[:y_end-y_start, :x_end-x_start, :] # 裁到实际尺寸 weight = weight[:y_end-y_start, :x_end-x_start, :] score_map[y_start:y_end, x_start:x_end] += pred * weight weight_map[y_start:y_end, x_start:x_end] += weight x_start += stride y_start += stride return np.argmax(score_map / weight_map, axis=-1).astype(np.uint8)

这个思路不需要改模型,直接部署就能用。一开始我也图省事,直接用互不重叠的切块推理,结果在接缝处容易出现条带状误检,后来加上重叠和加权融合才解决。

2.3 数据增强的取舍:不能全部照搬自然图像那套

PaddleSeg 内置了很多数据增强算子,但遥感场景要有选择地使用。随机翻转和随机旋转对遥感分割基本是安全的,因为遥感影像是下视视角,地物没有明显的"正立"概念。但有一些增强要谨慎,比如色彩抖动里的色相偏移,会把植被、水体等本来有明显光谱特征的地物搞得面目全非,反而降低模型泛化能力。

我实际启用的增强策略是:随机水平翻转和垂直翻转各 50% 概率,随机 90 度旋转(0/90/180/270 等概率),随机亮度对比度调整,以及随机尺度缩放(范围 0.75 到 1.5)。没有用随机裁剪,因为切块已经是裁剪了,再裁一次容易让地物失去上下文信息。比如道路这类细长地物,如果视野里只剩一小段,模型很难判断它到底是道路还是别的线性噪声。

关于增强的强度,我也做过对照:强增强比弱增强在训练集上的精度略低,但在验证集上的 mIoU 高了不少,这正是我们要的效果。遥感影像不同批次之间可能存在光照、季节差异,复杂的增强能让模型对这些变化更鲁棒。

3. 训练阶段的核心配置:模型选型、损失函数与超参调整

数据准备好之后,就到了模型训练阶段。这里说的核心配置不只是一条训练命令,而是包括网络结构选择、损失函数设计、学习率策略等一系列决定模型精度的细节。

3.1 模型选型:从 Backbone 到 Head 的搭配思路

PaddleSeg 支持多种模型结构,我在遥感项目里最常用的是 U-Net 和 DeepLabV3+。U-Net 的优势是结构简单、对小数据集友好,而且跳跃连接能比较好地保留地物边界细节。DeepLabV3+ 的优势在于空洞卷积带来的大感受野,对大面积均质地物(比如一整块农田或水面)的类内一致性更好。

我在这次项目里最终选了 DeepLabV3+,Backbone 用 ResNet-50,输出步长设为 8。这里有个细节值得说一下:很多分割模型默认的输出步长是 16,也就是最后输出的特征图分辨率是输入的 1/16,这对遥感影像来说太粗糙了。遥感地物边界往往只有几个像素宽,如果输出步长太大,边界信息基本就丢了。把 ASPP 模块里的空洞率对应调整,输出步长改到 8,边界质量会明显改善。

如果你有足够的算力,也可以试试 SegFormer-B0 或者 B1。我在另一个小规模实验里用了 SegFormer-B0,参数量比 ResNet-50 小得多,但 mIoU 竟然不相上下,尤其是在大面积地物上表现得更好。Transformer 的位置编码处理大尺寸输入时会有一些限制,但换到推理阶段需要整幅大图分割时,滑窗方案反而规避了这个问题。

3.2 损失函数:交叉熵打底,Dice Loss 处理类别不均衡

遥感分割的数据集几乎都有严重的类别不均衡问题。比如建筑物提取任务,建筑区域可能只占整体面积的 5% 以下,而背景占了 90% 以上。如果只用交叉熵损失,模型很容易陷入"把所有像素都预测成背景也能把 Loss 压得很低"的懒惰状态。

我的做法是多损失组合:主损失用带类别权重的交叉熵,辅助损失用 Dice Loss。PaddleSeg 里可以在 config 里配置多个 loss。我给出一个典型的配置片段:

loss: types: - type: CrossEntropyLoss weight: 1.0 ignore_index: 255 - type: DiceLoss weight: 1.0 ignore_index: 255 coef: [1.0, 0.5]

coef里的 1.0 和 0.5 分别对应两个损失的权重。Dice Loss 天然不依赖类别像素数的绝对比例,对前景占比很小的情况很友好。交叉熵则负责提供更精细的像素级梯度信号。这两个损失搭配起来,mIoU 的提升在遥感数据集上非常稳定。

类别权重怎么设置?我用的方法是按像素频率的倒数做开方。统计训练集里每个类别的像素占比,然后计算1 / sqrt(频率)作为该类的损失权重。这么做的好处是既放大了小类别的梯度信号,又不会让权重过大导致训练震荡。

3.3 训练超参:学习率、Batch Size 与迭代策略

PaddleSeg 训练遥感语义分割模型,我通常会用一个相对较小的 Batch Size,比如 4 或 8。因为遥感切块里的信息量太丰富了,一个小块可能包含几十个不同地物,本质上比自然图像的一个裁剪块要复杂,Batch Size 太大反而让模型收敛得慢。

学习率方面,我用的是初始 0.01 的 SGD,配 Poly 衰减策略。Poly 衰减的意思是学习率随迭代次数做lr * (1 - iter/total_iters)^0.9的衰减,这是分割任务里非常经典的做法。开头先用 5 个 epoch 的 warmup,从 0 线性升到 0.01,避免模型在初期大步长更新时震荡。

迭代轮数方面,对于遥感分割,我一般设置在 100 到 150 epoch 之间。我的一个朋友问过我,为什么遥感分割要比自然图像语义分割多训那么多轮?因为遥感数据集通常比较"稀疏",有效标注的地物区域占比低,模型需要更多轮次才能充分吸收这些少数类的特征。如果显存够大,Batch Size 调到 12 以上,那么 80 epoch 左右也能看到不错的效果,但再往上提升就没那么明显了。

训练过程中我会同时开启 PaddleSeg 的评估功能,每隔一定轮数在验证集上计算 mIoU,然后保存最优模型。这里有个建议:不要在最后一个 epoch 的模型上直接导出,而是选验证集 mIoU 最高的那一个 checkpoint。经验上,验证集最优模型比最后一个 epoch 模型在部署场景里的泛化能力好不少。

4. 转 ONNX 的完整链路:从 Paddle 模型到静态图导出

这一节是整个项目里坑最多的地方。PaddleSeg 训练完产出的是动态图模型,不能直接用 ONNX Runtime 加载,需要先转成静态图,再用 paddle2onnx 导出 ONNX 格式。这条链路每一步都有细节需要注意。

4.1 动态图转静态图:为什么要先走这一步

PaddlePaddle 本身有两种执行模式:动态图和静态图。PaddleSeg 训练时用的是动态图模式,方便调试;但 ONNX 导出需要的是一个结构完整的静态计算图,因为 ONNX 里描述的就是静态的数据流图。PaddleSeg 提供了export.py脚本,它做的事情就是用动态图权重初始化静态图模型,然后保存成推理用的静态图模型,包括.pdmodel和.pdiparams两个文件。

我用的导出命令大致是:

python tools/export.py \ --config configs/deeplabv3p/deeplabv3p_resnet50_os8_512x512_160k_remote.py \ --model_path output/best_model/model.pdparams \ --save_dir export_output/static_model

这里有一个重要的参数:--input_shape。如果你不指定,模型会按照训练时的输入尺寸导出,也就是 512×512。如果指定成1 3 512 512这种带 batch 维度的形式,导出的模型就固定了 batch size。对于遥感部署来说,推理时通常是一个 patch 一个 patch 地喂进去,所以 batch size 固定为 1 反而问题不大,还能简化导出。

但如果你希望同一个模型能够处理不同尺寸的输入,那就要导出时带-1动态维度。paddle2onnx支持动态 shape,不过 ONNX Runtime 端到端的推理性能会比固定 shape 略差一些,因为少了部分算子融合优化。我建议部署期采用固定 512×512 输入,既简单又高效。

4.2 paddle2onnx 导出与算子兼容性检查

静态图导出来之后,下一步就是核心的 ONNX 转换。PaddleSeg 官方推荐用 paddle2onnx 工具,安装和基本使用都很简单:

pip install paddle2onnx onnxruntime paddle2onnx \ --model_dir export_output/static_model \ --model_filename model.pdmodel \ --params_filename model.pdiparams \ --save_file export_output/model.onnx \ --opset_version 11 \ --enable_onnx_checker True

--opset_version 11是我比较推荐的选择。Opset 版本太低会缺少一些较新的算子,版本太高的话部分推理框架兼容性又要打问号,11 是目前 deploy 环境里兼容性最广的选择。如果你的部署端是 ONNX Runtime 1.7 以上,也可以选 12 或 13,但 11 永远是最稳的。

转换完成后,我用下面的方式做算子兼容性检查:

import onnx model = onnx.load("export_output/model.onnx") onnx.checker.check_model(model) print(onnx.helper.printable_graph(model.graph))

这一步不能省。在遥感模型里,偶尔会遇到某些自定义算子在转换时映射不到 ONNX 标准算子,paddle2onnx 会提示某个算子不支持。我这次用的 DeepLabV3+ 结构里没有遇到,但如果你用的模型里有自定义采样或者独特注意力机制,最好在导出前以官方算子文档为准核对一遍。如果不支持,常见的处理办法是把该算子替换成标准算子组合,或者干脆换一个结构相近的模型。

4.3 导出后的输入输出张量核对

ONNX 文件生成后,第一件事不是急着跑推理,而是把输入输出的张量名和 shape 搞清楚。ONNX 模型里的输入输出名称有时和 Paddle 里的命名不一样,后面写部署代码时要直接用 ONNX 里的名字。

import onnxruntime as ort sess = ort.InferenceSession("export_output/model.onnx", providers=["CPUExecutionProvider"]) for inp in sess.get_inputs(): print("Input:", inp.name, inp.shape, inp.type) for out in sess.get_outputs(): print("Output:", out.name, out.shape, out.type)

我这次导出的 ONNX 输入名称是x,shape 是[1, 3, 512, 512],输出名称是softmax_0.tmp_0,shape 是[1, 4, 512, 512]。如果你在部署阶段对模型做了后处理(比如 argmax),要么在 ONNX 模型里直接加一个 argmax 节点,要么在部署代码里用 numpy 处理。能加在模型里就加在模型里,省一次数据传输,也能减少 host 端的计算压力。

在 ONNX Runtime 试跑之前,我还习惯先用 Paddle 推理库跑一遍静态图模型,确保静态图导出的结果和动态图训练时的预测结果一致。这一步排除了很多"训练精度高、部署精度崩"的诡异问题。

4.4 ONNX 导出后精度对拍

这是很多人会忽略的一步:把同一个输入图片分别在 Paddle 动态图模型、Paddle 静态图模型和 ONNX Runtime 模型里各跑一遍,对比输出的预测结果差异。由于浮点计算顺序可能有微小差异,输出不会完全一致,但 argmax 之后的分割图应该几乎一模一样。

对拍流程我写成了一个简单脚本:

import numpy as np import onnxruntime as ort import paddle def load_paddle_model(model_path): model = paddle.jit.load(model_path) model.eval() return model def run_onnx(onnx_path, np_input): sess = ort.InferenceSession(onnx_path, providers=["CPUExecutionProvider"]) input_name = sess.get_inputs()[0].name output = sess.run(None, {input_name: np_input})[0] return output def compare_outputs(paddle_pred, onnx_pred, threshold=1e-4): paddle_argmax = np.argmax(paddle_pred, axis=1) onnx_argmax = np.argmax(onnx_pred, axis=1) diff = (paddle_argmax != onnx_argmax).sum() total = paddle_argmax.size print(f"mismatch ratio: {diff / total:.6f}")

如果 mismatch ratio 超过 0.1%,那一定有问题,通常是转换时某个算子计算方式发生了变化。我见过一次因为mean算子在不同框架里的keepdims行为不同,导致精度对不齐,排查了两天才发现。所以对拍这件事千万别跳。

5. 部署侧的验证与优化:ONNX Runtime、FP16 与性能实测

ONNX 文件导出来,不代表项目就结束了。部署侧的验证和优化同样重要,尤其是遥感影像这种输入尺寸大、语义分割输出也大的任务,推理延迟和内存占用都直接影响实际可用性。

5.1 ONNX Runtime 的部署代码骨架

先用最简单的 CPU 版本把流程跑通。这部分代码不复杂,重点是预处理方式要和训练时保持一致。比如训练时的归一化用的 mean 和 std,推理时也要一模一样,否则精度会莫名其妙地掉。

import numpy as np import onnxruntime as ort from PIL import Image sess = ort.InferenceSession("export_output/model.onnx", providers=["CPUExecutionProvider"]) input_name = sess.get_inputs()[0].name output_name = sess.get_outputs()[0].name def preprocess(pil_image): img = pil_image.resize((512, 512), Image.BILINEAR) arr = np.asarray(img, dtype=np.float32) / 255.0 mean = np.array([0.485, 0.456, 0.406], dtype=np.float32) std = np.array([0.229, 0.224, 0.225], dtype=np.float32) arr = (arr - mean) / std arr = arr.transpose(2, 0, 1) return arr[np.newaxis, ...] def postprocess(outputs): pred = np.argmax(outputs[0], axis=1)[0] return pred.astype(np.uint8) img = Image.open("test_patch.png") onnx_input = preprocess(img) outputs = sess.run([output_name], {input_name: onnx_input}) mask = postprocess(outputs) mask_pil = Image.fromarray(mask) mask_pil.save("test_patch_mask.png")

这里面的归一化参数要和你训练配置文件里的mean、std完全一致。我发现很多人训练完了直接把模型导出,却在部署代码里临时用了一套别的归一化,结果部署 mIoU 连着掉了十个百分点,回头找原因才发现是这个问题。

5.2 用半精度 FP16 做加速

遥感影像部署通常不会只处理一张 512×512 的小图,而是把一幅几千乘几千的大图切割成数百个 patch 逐个推理。这个场景下,半精度 FP16 推理能带来几乎一半的显存占用下降,推理速度也能提升。ONNX Runtime 提供了OrtSessionOptions来开启图优化:

import onnxruntime as ort opts = ort.SessionOptions() opts.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL sess = ort.InferenceSession( "export_output/model.onnx", sess_options=opts, providers=["CUDAExecutionProvider", "CPUExecutionProvider"], )

在支持 FP16 的设备上,ONNX Runtime 会把部分可以半精度计算的算子自动转换。不过我建议不要直接在 CPU 上做 FP16,CPU 上 FP16 反而可能更慢。具体做加速之前,最好先测一下 FP32 和 FP16 在精度上的差距。如果精度差异在 0.5% 以内,就可以放心用 FP16;如果差异明显,那就保持 FP32。我在某次实验中用 FP16 推理,mIoU 掉了不到 0.3%,但推理时间从 18ms 降到了 12ms,这买卖很划算。

5.3 精度与速度的最终实测

我在部署完整个项目之后记录了一组实测性能数据,拿来做参考。测试环境是单张入门级 GPU,输入尺寸统一为 512×512,类别数为 4。

部署方案平均推理时延 (ms)显存占用 (GB)相对精度
Paddle 动态图301.8基准
Paddle 静态图211.5近似基准
ONNX Runtime FP32181.4≈基准
ONNX Runtime FP16120.9微降

从这个实测结果可以看出,ONNX 导出本身已经带来了一些加速,加上半精度之后收益更明显。如果你想把性能再往上推一步,可以尝试用 TensorRT 加载 ONNX 做进一步的算子融合和显存优化,但那是另外一个话题了,这里不展开。

6. 我踩过的三个印象最深的坑

最后讲几个我在实际项目里踩过的坑,每一个都是花了好几天才走出来的,希望你能直接绕开。

第一个坑是训练和推理阶段的影像坐标范围不一致。我的训练数据是从某区域大图里裁出来的,推理阶段却直接甩给模型一整幅未归一化的原始 DN 值影像,结果模型输出了一片白噪声式的分割图,mIoU 直接归零。后来才发现推理图的数值范围和训练时的可见光三波段数据完全不在一个量级,归一化参数也完全变了。处理方法是推理前用和训练时一致的统计量重新做归一化。

第二个坑是类别数设置错误。训练时我在 config 里设置了 4 类,但导出的 ONNX 模型输出却是 3 类,排查了半天,原因是某个版本的 PaddleSeg 会自动忽略 Boost 类或者没有在配置中正确修改 num_classes。我建议导出后第一时间用onnx.helper.printable_graph打印输出 shape,人工确认类别数对不对。

第三个坑是切块推理时的重叠部分没有做加权融合,导致整幅大图拼接起来有明显隔断感,建筑物边缘变成了锯齿状。后来改用三角权重融合,效果立竿见影。遥感影像的地物边界本来就规整,如果拼接处出现条带,一眼就能看出来。

最后想说的是,PaddleSeg 转 ONNX 这件事本身不复杂,但它是一整套流程的终点。前面数据切得好不好、训练配置合理不合理,在部署阶段都会体现出来。把每个环节的"为什么"弄明白,比照抄任何一份配置都要重要。希望这篇内容能让你在遥感分割项目的部署路上少走几个来回。

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

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

立即咨询