简介:基于TensorRT的YOLOv5实例分割部署源码包,面向有C++/CUDA基础、希望深入学习模型推理加速的开发者,也适合计算机、电子、数学等专业学生作为课程设计或毕业设计的参考资料。压缩包共180个文件,整体约19.64MB,以编译目标文件、Makefile与Shell脚本、JSON与Proto配置、CUDA Kernel源文件为主,并包含预处理算子、HSigmoid/HSwish激活函数、H264码流转换、Base64图像传输等模块,目录结构清晰,能对照完整工程理解TensorRT部署链路。目前已有1591人学习下载。通过该源码可学习TensorRT C++ API调用、自定义CUDA算子注册与集成、多模块协同编译等关键技巧,同时可借鉴其中基于mongoose的嵌入式服务与图像上传接口设计,快速迁移到实际项目中。资料作为“参考资料”而非定制需求,要求使用者具备相应基础,能够自行调试并扩展功能,适合有独立排错能力的学习者。
1. 基于TensorRT部署YOLOv5实例分割:engine文件只是部署链路的一半
把一个训练好的YOLOv5实例分割模型搬到TensorRT,比纯检测模型多出来的工作量几乎全在mask这条输出线上。检测头返回的不只是框和类别,还有一个包含32个mask系数的张量,外加一张stride 4的proto特征图,后处理必须把两者做矩阵乘再sigmoid,才能还原出实例掩码。正因为多了这层,很多网上流传的TensorRT实例分割源码包,真正难复现的地方不在engine构建,而在输出张量名、动态shape、FP16精度这几个容易对不齐的点上。这篇文章按模型导出、引擎构建、后处理、工程落地四个阶段讲,参数和命令可直接抄,适合要自己训练数据集、再部署到Jetson Orin或PC端GPU的读者。
2. YOLOv5实例分割模型导出:输出张量形状与TensorRT版本匹配
动手构建engine之前,先把ONNX文件的两个输出搞清楚。YOLOv5 v7.0的segment分支沿用yolov5网络结构的backbone和PAN neck,只在检测头旁边多了一条proto分支,这条分支的输出在TensorRT里是一个独立绑定的张量,名字、形状、dtype都要与解析代码一一对应。
2.1 检测头与proto分支的输出约定
以640×640输入为例,名为output的输出形状是[1, 25200, 4+1+nc+32]。25200的来历是P3/P4/P5三个尺度的网格数之和:80×80加40×40加20×20等于8400,每格预测3个anchor,得到25200组预测。每组前4位是xywh框,第5位是objectness,接着nc位是类别分数,最后32位是mask系数。
另一个输出proto形状固定为[1, 32, 160, 160],对应stride 4的mask原型图。推理阶段用检测头保留的mask系数与proto做矩阵乘,结果经过sigmoid,就得到每个实例在160×160网格上的粗掩码。实例分割的后处理就是从这一个张量出发,裁出目标区域再放大回原图。
源码里这两个输出通常保持默认名output和proto。如果你在训练时改过模型yaml里的depth_multiple或width_multiple,输出张量的通道数会跟着变,解析代码里的硬编码数字都要随之修改,这是最容易埋雷的地方。
2.2 segment/export.py导出ONNX的命令与参数
YOLOv5官方仓库里,实例分割相关的训练和导出脚本在segment子目录下,不能用根目录的export.py,那只会导出检测模型。
python segment/export.py --weights yolov5s-seg.pt \ --include onnx --opset 12 --simplify --dynamic--include指定导出格式为onnx;--opset 12在TensorRT 8.x上兼容性最好;--simplify会调用onnx-simplifier清理导出时残留的冗余算子;--dynamic让batch维度保持动态。各参数的作用见下表。
| 参数 | 推荐值 | 作用 |
|---|---|---|
| --opset | 12 | TRT 8.5及以上稳定支持,opset 17需要TRT 8.6+ |
| --simplify | 开启 | 消除冗余节点,减小engine体积 |
| --dynamic | 开启 | 让batch可动态,建议高宽固定为640 |
| --include | onnx | 只生成onnx文件 |
提示:导出后立即检查输出名和形状,确认images这个输入名保留。TensorRT解析时按名字绑定张量,拼错一个字母都要查半天。
2.3 Jetson Orin上的TensorRT版本选型与降版本
TensorRT的engine文件与构建时的TRT版本强绑定,跨版本直接加载会报invalid engine或版本不匹配。Jetson Orin上TRT版本由JetPack决定,常见组合关系如下表。
| 平台 | JetPack/CUDA版本 | 自带TensorRT | 建议ONNX opset |
|---|---|---|---|
| Jetson Orin | JetPack 5.1.2 / CUDA 11.4 | 8.5.2 | 12 |
| Jetson Orin | JetPack 6.0 / CUDA 12.2 | 8.6.3 | 12 或 17 |
| PC端Ampere/Ada | CUDA 12.x | 10.x | 17 |
网上搜orin降tensorrt版本,多数情况发生在JetPack重刷之后:系统里的TRT版本和旧engine构建时不匹配,重建又遇到CUDA小版本不一致。降版本的做法是用pip指定版本号重装,装完执行python -c "import tensorrt; print(tensorrt.version)"验证。yolov5环境配置阶段就把tensorrt版本写进requirements锁定,能避免团队协作时每个人构建出不同行为的engine。
2.4 自己训练数据集后的输出形状检查
如果用yolov5训练自己的数据集,改了data.yaml里的nc后,output最后一维变成37+nc,也就是4+1+nc+32。类别顺序以训练时的data.yaml为准,部署端解码时类别索引必须与之一致,否则会出现框框对、类别全错的诡异现象,第一反应往往是数据加载问题,实际是索引没对齐。
超参数文件data/hyps/hyp.scratch-low.yaml里的增强配置不会改变输出形状,但会改变最终权重,因此模型重训后必须重新执行一次导出。导出一分钟的事,别省。
import onnx m = onnx.load("yolov5s-seg.onnx") for out in m.graph.output: shape = [d.dim_value for d in out.type.tensor_type.shape.dim] print(out.name, shape)这段脚本在导出后立刻确认输出名与形状。动态batch维的dim_value是0,看到0不用慌;只要名字是output和proto、最后一维等于37+nc,就可以进入构建步骤。
3. 构建TensorRT引擎:trtexec与Python API两条路径
拿到验证过的ONNX后,下一步是让TensorRT做图优化和层融合,落成engine文件。常见做法是先用trtexec命令行把流程跑通,再到代码里用Python API做同样的事,两条路径产出的engine等价,参数含义也一致。
3.1 trtexec最小命令与min/opt/max语义
/usr/src/tensorrt/bin/trtexec \ --onnx=yolov5s-seg.onnx \ --saveEngine=yolov5s-seg_fp16.engine \ --fp16 \ --minShapes=images:1x3x640x640 \ --optShapes=images:1x3x640x640 \ --maxShapes=images:4x3x640x640三个Shapes参数只针对输入张量images,proto输出由TRT根据网络自动推导,不需要手动写profile。min/opt/max分别代表推理时可接受的最小batch、性能优化基准batch和显存上限batch,建议值如下表。
| 参数 | 建议 | 影响 |
|---|---|---|
| --minShapes | 1 | 低于它的batch直接报错 |
| --optShapes | 实际最高频batch | TRT选kernel和tiling的依据 |
| --maxShapes | 4~8,视显存 | 超过上限会触发重新profile或失败 |
如果业务确认只跑单batch,我一般会把高宽直接固定在640,连动态batch都不开。这样proto固定为[1, 32, 160, 160],后处理里的缩放因子恒定,整个链路的可复现性高很多。实例分割比检测更值得这么做,因为proto的空间维度跟随输入分辨率变化,动态高宽会让后处理代码多出一堆分支。
3.2 用Python API在程序内构建engine
import tensorrt as trt def build_engine(onnx_path, save_path, max_batch=4): logger = trt.Logger(trt.Logger.WARNING) builder = trt.Builder(logger) network = builder.create_network( 1 << int(trt.NetworkDefinitionCreationFlag.EXPLICIT_BATCH) ) parser = trt.OnnxParser(network, logger) with open(onnx_path, "rb") as f: assert parser.parse(f.read()), "ONNX解析失败" config = builder.create_builder_config() if builder.platform_has_fast_fp16: config.set_flag(trt.BuilderFlag.FP16) profile = builder.create_optimization_profile() profile.set_shape("images", (1, 3, 640, 640), (1, 3, 640, 640), (max_batch, 3, 640, 640)) config.add_optimization_profile(profile) serialized = builder.build_serialized_network(network, config) if serialized is None: raise RuntimeError("engine构建失败,检查ONNX与TRT版本") with open(save_path, "wb") as f: f.write(serialized)create_network必须加EXPLICIT_BATCH标志,因为导出的ONNX带动态batch,不加会解析失败。platform_has_fast_fp16用于确认当前GPU能跑FP16,桌面卡和Jetson基本都是True。build_serialized_network失败时经常不抛异常而是返回None,显式判一下更稳妥。workspace内存默认按显存分配,在Jetson这类小显存设备上建议显式限制:
config.set_memory_pool_limit(trt.MemoryPoolType.WORKSPACE, 2 << 30)2GB的临时缓冲区对yolov5s-seg级模型足够,也避免把整个显存吃满。
3.3 FP16与INT8的精度取舍
FP16对实例分割mask的精度损失通常在可接受范围,实测常见mAP掉点不到一个点,边缘轮廓偶尔出现锯齿。INT8想保住mask质量,需要准备500到1000张有代表性的校准图跑EntropyCalibrator2,而且分割这种细节敏感任务对校准集分布特别挑剔,人物占比高的业务必须把人物图放进去,否则背景mask会成片漏检。
两种精度的取舍经验如下表。
| 精度 | 是否需要校准集 | 端到端加速经验值 | mask掉点风险 |
|---|---|---|---|
| FP16 | 否 | 相对FP32约1.2~1.8倍 | 低,个别边缘锯齿 |
| INT8 | 是,500张以上 | 约2.5~3倍 | 高,需逐类验证 |
我的建议是第一步只开FP16,先把整条流水线跑通、精度对齐,验证满足指标后再考虑INT8。另外注意后处理里矩阵乘的dtype,numpy用float32足够,别把mask系数和proto乘出来后又转成float16累加,那点精度损失在crop后会被放大,mask边界会布满噪点。
3.4 构建期常见报错与定位
- ONNX解析失败,报Could not parse ONNX model:先查opset,opset 12的模型在TRT 8.5以下版本会解析不过;版本没问题就回导出步骤重新导一次。
- 报Cannot find input: images:输入名不是images,用2.4节的形状脚本看实际名,按实际名改profile。
- 报显存不足:把maxShapes降下来,或给workspace设上限,Jetson上优先用set_memory_pool_limit。
- engine构建成功但推理结果全是0:多半是输入预处理或输出buffer形状不匹配,先回到单batch跑一遍trtexec看输出是否正常。
把这几类错误在源码工程里做成预处理检查,直接抛中文异常,比让下游调用方看一份英文TRT日志高效得多。
4. YOLOv5实例分割后处理:mask解码、NMS与精度对齐
引擎推理返回的两个原始张量不能直接当作可视化结果。output里25200组预测要先筛置信度再过NMS,proto要和保留下的mask系数做矩阵乘。这一章给的numpy实现直接对标yolov5仓库里的process_mask逻辑。
4.1 从output张量筛选目标框与置信度
import numpy as np def decode_detection(output, nc, conf_thres=0.25): pred = output[0] # [25200, 4+1+nc+32] cls_score = pred[:, 5:5 + nc] cls_id = cls_score.argmax(1) conf = pred[:, 4] * cls_score[np.arange(len(pred)), cls_id] keep = conf >= conf_thres boxes_xywh = pred[keep, :4].copy() scores = conf[keep] coeffs = pred[keep, 5 + nc:5 + nc + 32] # mask系数 # xywh转xyxy,YOLOv5的框是中心点加宽高 boxes = boxes_xywh.copy() boxes[:, 0] = boxes_xywh[:, 0] - boxes_xywh[:, 2] / 2 boxes[:, 1] = boxes_xywh[:, 1] - boxes_xywh[:, 3] / 2 boxes[:, 2] = boxes_xywh[:, 0] + boxes_xywh[:, 2] / 2 boxes[:, 3] = boxes_xywh[:, 1] + boxes_xywh[:, 3] / 2 return boxes, scores, cls_id[keep], coeffs导出后的ONNX里,output张量已经带解码后的像素坐标和sigmoid过的objectness与类别分数,这里直接相乘得到综合置信度,不需要再做一次激活。先过滤掉绝大多数背景候选,把25200筛到几百个,再做NMS。mask系数的索引起点是5加nc,单独截出来留给下一步解码。
4.2 用proto与mask系数解码实例掩码
def decode_masks(protos, coeffs, boxes, img_size=640): c = protos.shape[0] masks = (coeffs @ protos.reshape(c, -1)) # [N, 160*160] masks = 1.0 / (1.0 + np.exp(-masks)) # sigmoid masks = masks.reshape(-1, 160, 160) # 每个实例一张160x160 ratio = 160 / img_size for i, (x1, y1, x2, y2) in enumerate(boxes): x1, y1, x2, y2 = int(x1 * ratio), int(y1 * ratio), int(x2 * ratio), int(y2 * ratio) x1, y1 = max(x1, 0), max(y1, 0) x2, y2 = min(x2, 160), min(y2, 160) crop = masks[i, y1:y2, x1:x2].copy() masks[i] = 0 masks[i, y1:y2, x1:x2] = crop return masks这段对应yolov5里的scaled_boxes加crop_mask。关键点是缩放比例必须用proto尺寸除以原图尺寸,输入640时是四分之一,输入1280时变成八分之一。矩阵乘方向别写反,coeffs在左、proto展平在右,形状是[N, 32]乘[32, 25600]。crop后对每个实例做cv2.resize到原图尺寸,再按0.5阈值转成二值mask,就是最终分割结果。
注意:如果原图经过letterbox处理,box和mask坐标都在letterbox后的画布上,映射回原图时要先减去pad偏移再除以缩放比,这一步漏掉会导致整幅图的mask整体错位。
4.3 NMS实现与阈值参数表
NMS直接用numpy写就行,不必为此引入torch。按分数降序,依次保留当前最高分框,扔掉与它IoU超过阈值的其余框,循环到候选为空:
def nms(boxes, scores, iou_thres=0.45): order = scores.argsort()[::-1] keep = [] while order.size > 0: i = order[0] keep.append(i) ious = box_iou(boxes[i], boxes[order[1:]]) order = order[1:][ious < iou_thres] return np.array(keep)yolov5默认按类别分别做NMS,上面这段是class-agnostic的实现,直接整批跑会把不同类别的重叠框误删;要复现默认行为,就按cls_id分组后逐组调用nms。检测框和mask共用同一组筛选结果,NMS在mask解码之前做,能省掉大量无效矩阵乘。阈值参数沿用训练侧默认值就好:
| 参数 | 默认值 | 调节方向 |
|---|---|---|
| conf_thres | 0.25 | 调低提升召回,代价是NMS耗时上升 |
| iou_thres | 0.45 | 调高抑制重叠目标,密集场景调整 |
| mask阈值 | 0.5 | 调低mask边缘更膨大,按可视化需求微调 |
4.4 与PyTorch结果做tensor级对齐
部署完第一件事是对齐。同一张图分别走yolov5的torch推理和TensorRT流水线,对比框的IoU和mask的IoU。对齐脚本里最关键的是预处理完全一致:letterbox的缩放、填充颜色、归一化方式都不能有微小差异,很多说TensorRT精度不行的结论,最后查出来都是预处理没复制到位。
def mask_iou(a, b): inter = np.logical_and(a, b).sum() union = np.logical_or(a, b).sum() return inter / max(union, 1)我一般以box IoU大于0.9、mask IoU大于0.85作为通过线。不一致时先查预处理,再查NMS顺序,最后才怀疑FP16。顺序颠倒会把半小时的排错拖成一下午。
5. 源码工程落地:显存管理、动态batch与精度回归技巧
engine有了、后处理通了,最后是把几块拼进真正能长期跑的源码工程里。这里讲三个最影响生产的落地细节,都是只影响稳定性、不影响demo正确性的点。
5.1 engine生命周期与输出buffer预分配
engine加载一次后整个进程复用,context按线程各持一个,避免多线程推理时上下文互踩。输出buffer按maxBatch预分配,推理循环里只做host与device之间的拷贝,不要每次推理都cudaMalloc,频繁申请会让显存碎片化,跑一晚上之后出现莫名的分配失败。TensorRT要求buffer地址512字节对齐,Python侧用cupy分配默认满足,C++侧cudaMalloc也满足,别拿普通vector 的data直接传。推理时两个输出tensor的地址固定,只在batch变化时整体换一套buffer,这个约定能省掉每次查询张量名的开销。
5.2 optShapes要贴近真实batch
很多工程把optShapes设成maxShapes,认为这样上限最高,实际会让单路推理变慢。TRT按optShapes选择kernel和向量化宽度,业务固定1路输入时,opt设8意味着按8路的tiling策略执行,单batch反而多算了无效数据。常见做法是optShapes等于生产环境出现频率最高的batch,minShapes设1兜底,maxShapes设成显存允许的上限应对突发。
batch切换时记得调用context.set_input_shape,并且确认输出buffer容量大于新batch。这个坑在压测脚本里最常见:batch从1切到4,程序不报错,结果tensor被截断,mask错得莫名其妙。
5.3 把精度回归脚本变成CI门禁
最后一个技巧是把4.4节的对齐逻辑固化成回归脚本,放进源码仓库的scripts目录,每次换权重、升TensorRT版本、改后处理参数后自动跑一遍:
for img, gt_mask in zip(valid_images, gt_masks): boxes, masks = trt_infer(img) # TRT整条流水线 ref_boxes, ref_masks = torch_infer(img) # 原PyTorch流水线 assert mask_iou(masks[0], ref_masks[0]) > 0.85 assert box_iou(boxes[0], ref_boxes[0]) > 0.9torch侧固定随机种子,只跑eval模式,数据取验证集里框数量差异大的100张图,能同时覆盖单目标、密集行人、大目标占满画面三类场景。回归不通过时先看预处理差异,再看阈值参数,最后才动FP16。脚本失败直接让CI标红,比靠肉眼对比两张可视化图可靠得多,也避免"上次还能跑、这次效果变了"这类问题在项目里反复出现。
本文还有配套的精品资源,点击获取