【Unet++ MobileNetv2语义分割部署至RK3588】模型训练→转换RKNN→开发板部署

已在GitHub开源与本博客同步的Unetplusplus_MobileNetv2-Semantic-Segmentation项目,
地址:Unetplusplus_MobileNetv2-Semantic-Segmentation
详细使用教程,可参考README.md或参考本博客第七章 模型部署
注:本文是以瑞芯微RK3588 SoC进行示例,同时也可支持瑞芯微其他系列SoC:RK3562、RK3566、RK3568、RK3576、RK3399PRO、RK1808、RV1126、RV1126B、RV1103、RV1106、RV1109等,部署流程也基本一致,如需帮助,可通过Github仓库的 README.md 沟通。
文章目录
- 一、项目回顾
- 二、文件梳理
- 三、语义分割数据集的准备
- 四、SMP框架的模型训练
- 五、PT转ONNX
- 六、ONNX转RKNN
- 七、模型部署
一、项目回顾
博主之前有写过YOLO11、YOLOv8目标检测&图像分割、YOLOv10、v5目标检测、MoblieNetv2、ResNet50图像分类的模型训练、转换、部署文章,感兴趣的小伙伴可以了解下:
【YOLO11-obb部署至RK3588】模型训练→转换RKNN→开发板部署
【YOLO11部署至RK3588】模型训练→转换RKNN→开发板部署
【YOLOv10部署RK3588】模型训练→转换rknn→部署流程
【YOLOv8-obb部署至RK3588】模型训练→转换RKNN→开发板部署
【YOLOv8-pose部署至RK3588】模型训练→转换RKNN→开发板部署
【YOLOv8seg部署RK3588】模型训练→转换rknn→部署全流程
【YOLOv8部署至RK3588】模型训练→转换rknn→部署全流程
【YOLOv7部署至RK3588】模型训练→转换RKNN→开发板部署
【YOLOv6部署至RK3588】模型训练→转换RKNN→开发板部署
【YOLOv5部署至RK3588】模型训练→转换RKNN→开发板部署
【MobileNetv2图像分类部署至RK3588】模型训练→转换rknn→部署流程
【ResNet50图像分类部署至RK3588】模型训练→转换RKNN→开发板部署
YOLOv8n部署RK3588开发板全流程(pt→onnx→rknn模型转换、板端后处理检测)
最近在搞语义分割的项目,涉及到模型选型(比如Unet++配MobileNetV2)、训练调试、以及后续转ONNX和RKNN,最终部署到RK3588开发板上。在原型验证阶段,发现如果手动去实现各种分割模型(如Unet, FPN, DeepLabV3+)并适配不同的Backbone(如ResNet, MobileNet),非常繁琐且容易出错。
查了下GitHub,发现 qubvel-org / segmentation_models.pytorch (SMP) 这个项目非常优秀。它把主流的分割架构和海量的预训练Backbone都集成好了,API调用极其简洁,几行代码就能构建和训练一个强大的分割模型。用这个库的人非常多,但专门结合嵌入式部署到瑞芯微的RK3588上的文章不多,遂开此文,重点介绍下这个利器,相互学习。
二、文件梳理
CSDN上已有很多的Unet++的训练文章,但原有的Unet++训练是比较封闭的,所以查看了这个SMP项目,可以自己自定义编码器的解码器,当然,该项目只适用于语义分割任务。
目前需要的文件有如下几个:
1:SMP项目(尽量选0.5.0版本的)(链接在此)
2:在虚拟机中进行onnx转rknn的虚拟环境配置项目文件(链接在此);
3:onnx转rknn的文件(链接在此);(rknn-toolkit2尽量和rknn_model_zoo版本一致)
如下所示:



三、语义分割数据集的准备
博主主要用的是Labelme,用多边形把每个目标勾出来,分配好类别(比如背景=0,人=1,车=2)。但Labelme导出的JSON还不能直接拿去训练,关键一步是需要写脚本,把JSON批量转成模型能认的单通道PNG Mask图,图里每个像素的值就是它的类别索引。(至于脚本如何写,建议直接让DeepSeek生成)
此处示例如下:
原图:

标签图:

标签图基本都是黑的,以像素值进行类别区分。
数据集的标准格式如下所示:

train和val是原图,trainannot和valannot是黑色的标签图
四、SMP框架的模型训练
先进性环境配置,参考requirements下的required.txt、docs.txt、minimum.old:



完成环境配置。博主的环境如下所示:
# packages in environment at /root/anaconda3/envs/smp050:
#
# Name Version Build Channel
_libgcc_mutex 0.1 main
albucore 0.0.24 <pip>
albumentations 2.0.8 <pip>
annotated-types 0.7.0 <pip>
ca-certificates 2025.2.25 h06a4308_0
certifi 2025.4.26 <pip>
charset-normalizer 3.4.2 <pip>
contourpy 1.3.0 <pip>
cycler 0.12.1 <pip>
eval_type_backport 0.2.2 <pip>
filelock 3.18.0 <pip>
fonttools 4.58.2 <pip>
fsspec 2025.5.1 <pip>
hf-xet 1.1.3 <pip>
huggingface-hub 0.33.0 <pip>
idna 3.10 <pip>
imageio 2.37.0 <pip>
importlib_resources 6.5.2 <pip>
kiwisolver 1.4.7 <pip>
ld_impl_linux-64 2.40 h12ee557_0
libffi 3.3 he6710b0_2
libgcc-ng 9.1.0 hdf63c60_0
libstdcxx-ng 9.1.0 hdf63c60_0
matplotlib 3.9.4 <pip>
ncurses 6.3 h7f8727e_2
numpy 1.24.4 <pip>
opencv-python 4.11.0.86 <pip>
opencv-python-headless 4.11.0.86 <pip>
openssl 1.1.1w h7f8727e_0
packaging 25.0 <pip>
pillow 11.2.1 <pip>
pip 25.1 pyhc872135_2
pydantic 2.11.5 <pip>
pydantic_core 2.33.2 <pip>
pyparsing 3.2.3 <pip>
python 3.9.12 h12debd9_1
python-dateutil 2.9.0.post0 <pip>
PyYAML 6.0.2 <pip>
readline 8.1.2 h7f8727e_1
requests 2.32.4 <pip>
safetensors 0.5.3 <pip>
scipy 1.13.1 <pip>
setuptools 78.1.1 py39h06a4308_0
simsimd 6.4.9 <pip>
six 1.17.0 <pip>
sqlite 3.38.5 hc218d9a_0
stringzilla 3.12.5 <pip>
timm 1.0.15 <pip>
tk 8.6.12 h1ccaba5_0
torch 1.10.0+cu111 <pip>
torchvision 0.11.0+cu111 <pip>
tqdm 4.67.1 <pip>
typing-inspection 0.4.1 <pip>
typing_extensions 4.14.0 <pip>
tzdata 2025b h04d1e81_0
urllib3 2.4.0 <pip>
wheel 0.45.1 py39h06a4308_0
xz 5.2.5 h7f8727e_1
zipp 3.23.0 <pip>
zlib 1.2.12 h7f8727e_2
环境配置完成后,需要训练脚本,如果直接下载SMP,是没有合适的训练脚本的,博主这里提供自己的训练脚本train050.py,如下所示:
import os
os.environ['CUDA_VISIBLE_DEVICES'] = '0'import numpy as np
import torch
import torch.optim as optim
import segmentation_models_pytorch as smp
from segmentation_models_pytorch.utils.metrics import IoU
from torch.utils.data import DataLoader
from torch.utils.data import Dataset as BaseDataset
import albumentations as albu
import time
import cv2
from tqdm import tqdm # 添加进度条库
import warnings# 忽略权重加载警告
warnings.filterwarnings("ignore", message="Error loading mobilenet_v2 `imagenet` weights.*")# 数据集目录
DATA_DIR = 'xxxxx'
x_train_dir = os.path.join(DATA_DIR, 'train')
y_train_dir = os.path.join(DATA_DIR, 'trainannot')
x_valid_dir = os.path.join(DATA_DIR, 'val')
y_valid_dir = os.path.join(DATA_DIR, 'valannot')# 模型参数
ENCODER = 'mobilenet_v2'
ENCODER_WEIGHTS = 'imagenet'
CLASSES = ['xxx']
ACTIVATION = 'sigmoid'
DEVICE = 'cuda'
BATCH_SIZE = 16
LR = 0.0001
EPOCHS = 800# 定义目标图像尺寸 (方便管理)
TARGET_SIZE = 640# 数据加载类
class SegmentationDataset(BaseDataset):CLASSES = ['carpet']def __init__(self, images_dir, masks_dir, augmentation=None, preprocessing=None):self.ids = os.listdir(images_dir)self.images_fps = [os.path.join(images_dir, img_id) for img_id in self.ids]self.masks_fps = [os.path.join(masks_dir, img_id) for img_id in self.ids]self.augmentation = augmentationself.preprocessing = preprocessingself.class_value = 1 # 二分类任务def __getitem__(self, i):image = cv2.imread(self.images_fps[i])image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)mask = cv2.imread(self.masks_fps[i], 0)mask = (mask == self.class_value).astype('float')if self.augmentation:sample = self.augmentation(image=image, mask=mask)image, mask = sample['image'], sample['mask']if self.preprocessing:sample = self.preprocessing(image=image, mask=mask)image, mask = sample['image'], sample['mask']return image, maskdef __len__(self):return len(self.ids)def get_training_augmentation():return albu.Compose([albu.HorizontalFlip(p=0.5),albu.Affine(scale=(0.8, 1.2), # 调整缩放范围以适应letterboxtranslate_percent=(-0.1, 0.1),rotate=10, # 可以加入轻微旋转p=0.7),# 核心修改:先缩放(保持比例),再填充albu.LongestMaxSize(max_size=TARGET_SIZE, interpolation=cv2.INTER_LINEAR, p=1), albu.PadIfNeeded( min_height=TARGET_SIZE, min_width=TARGET_SIZE, p=1, border_mode=cv2.BORDER_CONSTANT, value=[128, 128, 128], # 图像填充值 (灰色)mask_value=0 # mask填充值 (黑色)),albu.GaussNoise(p=0.2),albu.RandomBrightnessContrast(p=0.2),])def get_validation_augmentation():return albu.Compose([albu.LongestMaxSize(max_size=TARGET_SIZE, interpolation=cv2.INTER_LINEAR, p=1), albu.PadIfNeeded( min_height=TARGET_SIZE, min_width=TARGET_SIZE, p=1, border_mode=cv2.BORDER_CONSTANT, value=[128, 128, 128], # 图像填充值 (灰色)mask_value=0 # mask填充值 (黑色))])# 处理单通道Mask和多通道Image
def to_tensor(x, **kwargs):if x.ndim == 2: # 处理单通道Mask (H, W)x = np.expand_dims(x, axis=0) # 增加通道维 -> (1, H, W)else: # 处理RGB图像 (H, W, C)x = x.transpose(2, 0, 1) # 转置为 (C, H, W)return x.astype('float32')# 预处理
preprocessing_fn = smp.encoders.get_preprocessing_fn(ENCODER, ENCODER_WEIGHTS)def get_preprocessing(preprocessing_fn):return albu.Compose([albu.Lambda(image=preprocessing_fn),albu.Lambda(image=to_tensor, mask=to_tensor),])# 创建数据加载器
train_dataset = SegmentationDataset(x_train_dir, y_train_dir,augmentation=get_training_augmentation(),preprocessing=get_preprocessing(preprocessing_fn),
)valid_dataset = SegmentationDataset(x_valid_dir, y_valid_dir,augmentation=get_validation_augmentation(),preprocessing=get_preprocessing(preprocessing_fn),
)train_loader = DataLoader(train_dataset, batch_size=BATCH_SIZE, shuffle=True, num_workers=4)
valid_loader = DataLoader(valid_dataset, batch_size=BATCH_SIZE, shuffle=False, num_workers=4)# 创建模型
model = smp.create_model('unetplusplus',encoder_name=ENCODER, encoder_weights=ENCODER_WEIGHTS,in_channels=3,classes=1,activation=ACTIVATION,
)
model.to(DEVICE)# 损失函数
loss_fn = smp.losses.DiceLoss(mode='binary')# 优化器
optimizer = optim.Adam(model.parameters(), lr=LR)# 创建 IoU 计算器实例
iou_metric = IoU(threshold=0.5).to(DEVICE)# 在训练循环之前创建模型目录
MODEL_DIR = 'xxx'
os.makedirs(MODEL_DIR, exist_ok=True) # 确保目录存在# 训练循环
max_iou = 0
best_model_path = None # 用于记录当前最佳模型的路径for epoch in range(EPOCHS):print(f'\nEpoch {epoch+1}/{EPOCHS}:')# ===== 训练阶段 =====model.train()train_loss = 0.0train_bar = tqdm(train_loader, desc='Training', unit='batch', dynamic_ncols=True)for images, masks in train_bar:images, masks = images.to(DEVICE), masks.to(DEVICE)optimizer.zero_grad()outputs = model(images)loss = loss_fn(outputs, masks)loss.backward()optimizer.step()train_loss += loss.item()train_bar.set_postfix(loss=f'{loss.item():.4f}', lr=f"{optimizer.param_groups[0]['lr']:.6f}")# ===== 验证阶段 =====model.eval()valid_loss = 0.0iou_scores = []valid_bar = tqdm(valid_loader, desc='Validating', unit='batch', dynamic_ncols=True)with torch.no_grad():for images, masks in valid_bar:images, masks = images.to(DEVICE), masks.to(DEVICE)outputs = model(images)loss_val = loss_fn(outputs, masks)valid_loss += loss_val.item()batch_iou = iou_metric(outputs, masks).item()iou_scores.append(batch_iou)valid_bar.set_postfix(val_loss=f'{loss_val.item():.4f}', iou=f'{batch_iou:.4f}')# 计算平均指标train_loss /= len(train_loader)valid_loss /= len(valid_loader)mean_iou = np.mean(iou_scores)print(f'[Summary] Train Loss: {train_loss:.4f} | Valid Loss: {valid_loss:.4f} | IoU: {mean_iou:.4f}')# 保存最佳模型if mean_iou > max_iou:max_iou = mean_iouif best_model_path and os.path.exists(best_model_path):os.remove(best_model_path)print(f"删除旧的最佳模型: {best_model_path}")model_name = f'best_model_iou_{mean_iou:.4f}.pth'model_path = os.path.join(MODEL_DIR, model_name)torch.save(model.state_dict(), model_path)best_model_path = model_pathprint(f'[Model Saved] 新的最佳模型保存至: {model_path}')# 学习率调整if epoch == 50:optimizer.param_groups[0]['lr'] = LR / 10print('Learning rate decreased to 1e-5')
注意:需要将脚本中的各个数据集路径都适配好。
这里要着重说一下,因为Unet++作为解码器的效果是比较好的,而MobileNetv2作为轻量级网络的编码能力也很优秀,所以在当前脚本中,是将Unet++和MobileNetv2结合起来,实现语义分割模型的整体架构,既保证性能,又实现轻量化。
现在可以开始训练,如下所示:

训练完成后,会在model文件夹下保存最优模型:

五、PT转ONNX
同样博主在此处提供专用的pt转onnx的脚本:pt2onnx.py:
import os
import torch
import segmentation_models_pytorch as smp# 配置参数
MODEL_PATH = 'xxx.pth'
ONNX_PATH = 'xx.onnx'
ENCODER = 'mobilenet_v2'
ENCODER_WEIGHTS = 'imagenet'
CLASSES = ['xxx']
ACTIVATION = 'sigmoid'
INPUT_SHAPE = (1, 3, 640, 640) # 固定尺寸
DEVICE = 'cuda' if torch.cuda.is_available() else 'cpu'
OP_SET_VERSION = 12def main():print(f"使用设备: {DEVICE}")print("正在创建模型结构...")model = smp.create_model('unetplusplus',encoder_name=ENCODER,encoder_weights=ENCODER_WEIGHTS,in_channels=3,classes=1,activation=ACTIVATION,)print(f"加载训练权重: {MODEL_PATH}")state_dict = torch.load(MODEL_PATH, map_location=DEVICE)model.load_state_dict(state_dict)model.to(DEVICE)model.eval()# 准备固定尺寸的虚拟输入print("准备固定尺寸的虚拟输入数据...")dummy_input = torch.randn(INPUT_SHAPE, device=DEVICE)# 导出模型到ONNX格式(固定尺寸)print(f"导出固定尺寸ONNX模型...")torch.onnx.export(model,dummy_input,ONNX_PATH,export_params=True,opset_version=OP_SET_VERSION,do_constant_folding=True,input_names=['input'],output_names=['output'],dynamic_axes=None # 关键修改:移除动态维度)print(f"✓ 固定尺寸ONNX模型已保存至: {ONNX_PATH}")# 验证输出print("\n验证输出:")with torch.no_grad():torch_output = model(dummy_input)print(f"输入形状: {dummy_input.shape}")print(f"输出形状: {torch_output.shape}")print(f"输出应为固定尺寸: (1, 1, 640, 640)")if __name__ == '__main__':main()
修改pt2onnx.py中的pt和onnx模型的路径,然后执行脚本,结果如下所示:

执行完脚本后,在脚本中的指定路径下生成onnx模型
六、ONNX转RKNN
在进行这一步的时候,如果你是在云服务器上运行,请先确保你租的卡能支持RKNN的转换运行。博主是在自己的虚拟机中进行转换。
先安装转换环境
这里我们先conda create -n rknn232 python=3.8创建环境,创建完成后激活该环境,打开第三个文件:rknn_toolkit2-2.3.2,在图示路径下找到这两个文件:

在终端激活环境,在终端输入
pip install -r requirements_cp38-2.3.2.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
然后再输入
pip install rknn_toolkit2-2.3.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
然后,我们的转rknn环境就配置完成了。
现在开始转换RKNN模型:
先进入rknn_model_zoo-2.3.2/examples/ppseg/model文件夹,将刚才得到的ONNX模型复制进来(我演示的位置是2.1版本的rknn_model_zoo,但操作流程是一致的):

打开ppseg/python下的convert.py,修改参数,如下所示:

激活环境,执行该脚本开始量化,如下所示:
然后在model文件夹下生成我们所要的rknn模型:
用netron打开,如下所示,我只有一个类别输出通道:

七、模型部署
如果前面流程都已实现,模型的结构也没问题的话,则可以进行最后一步:模型端侧部署。
我已经帮大家做好了所有的环境适配工作,科学上网后访问博主GitHub仓库:Unetplusplus_MobileNetv2-Semantic-Segmentation

git clone后把项目复制到开发板上,按如下流程操作:
①:cd build,删除所有build文件夹下的内容
②:cd src 修改main.cc,修改main函数中的如下三个内容,将这三个参数改成自己的绝对路径:

③:把你之前训练好并已转成RKNN格式的模型放到Unetplusplus_MobileNetv2-Semantic-Segmentation/model文件夹下,然后把你要检测的所有图片都放到Unetplusplus_MobileNetv2-Semantic-Segmentation/inputimage下。
在运行程序后,生成的结果图片在Unetplusplus_MobileNetv2-Semantic-Segmentation/outputimage下
④:进入build文件夹进行编译
cd build
cmake ..
make
在build下生成可执行文件文件:rknn_yolo11obb_demo
在build路径下输入
./rknn_Unet++_MobileNetv2_demo
注:我已经在GIthub的仓库中按照路径放置好了本博客所使用到的所有文件,包括rknn模型、输入图片、输出图片以及txt文档,只需修改cpp中的文件路径即可,或者可参考main.cc中的main函数,按照如下命令亦可:
原inputimage文件夹下的图片:

在修改完main.cc中main函数的路径参数后执行完.rknn_Unet++_MobileNetv2_demo后在outputimage下的输出结果图片:

根据输出结果,可以看到语义分割效果还是可以的。
上述即博主此次更新的Unetplusplus_MobileNetv2-Semantic-Segmentation部署RK3588,包含PT转ONNX转RKNN的全流程步骤,欢迎交流!
