一、前言:YOLOv5后处理到底要做什么?
你复习的这份笔记核心是解决「模型输出→实际检测框」的转换问题:
- YOLOv5模型的输出是一个
[n, 85]的张量(n是预测框数量,85=中心坐标cx/cy + 宽高w/h + 目标置信度obj + 80类分类概率); - 后处理的核心任务:
- 解码:把
[cx, cy, w, h]转换成实际的检测框坐标[left, top, right, bottom],并过滤低置信度的框; - NMS(非极大值抑制):去掉同一目标的重复检测框(比如一个人被多个框框住,只留置信度最高的);
- 解码:把
- 加速目标:CPU版后处理在检测框数量大时速度慢,用CUDA核函数并行处理每个预测框,把后处理时间从“毫秒级”压到“微秒级”。
二、核心概念:先搞懂YOLOv5后处理的基础规则
用「招聘筛选」比喻帮你理解:
| 后处理步骤 | 技术逻辑 | 生活化比喻 |
|---|---|---|
| 解码 | 1. 计算置信度(obj×类概率) 2. 过滤置信度低于阈值(比如0.25)的框 3. 把cx/cy/w/h转成左上/右下坐标 | 1. 看简历的“综合评分”(obj=基础分,类概率=岗位匹配分) 2. 淘汰综合分低于60分的候选人 3. 把候选人的“模糊地址”转成“精确地址” |
| NMS | 1. 按置信度排序 2. 去掉和高置信度框重叠度(IoU)超过阈值(比如0.45)的重复框 | 1. 按综合分从高到低排候选人 2. 淘汰和第一名重复的候选人(比如同一个人投了多个岗位) |
三、后处理案例拆解:CPU版→GPU版
笔记里先讲CPU版(易理解、打基础),再讲GPU版(并行加速),我们逐段拆解。
3.1 先明确:YOLOv5输出的格式约定
模型输出的每个预测框是85个数值,顺序是:[cx, cy, width, height, objectness, class1, class2, ..., class80]
objectness:这个框里“有目标”的概率(0~1);class1~class80:这个框属于80类(COCO数据集)中某一类的概率;- 最终置信度 = objectness × 最高类概率(比如obj=0.8,类概率=0.9→置信度=0.72)。
3.2 CPU_decode:后处理的“手工筛选版”
CPU版是后处理的“基础实现”,核心是「逐框处理+有序去重」,先看代码结构,再拆解关键逻辑。
3.2.1 核心代码
// 先定义Box结构体:存检测框的坐标、置信度、类别structBox{floatleft,top,right,bottom,confidence;floatlabel;Box(floatl,floatt,floatr,floatb,floatc,floatlb):left(l),top(t),right(r),bottom(b),confidence(c),label(lb){}};vector<Box>cpu_decode(float*predict,introws,intcols,floatconfidence_threshold=0.25f,floatnms_threshold=0.45f){vector<Box>boxes;intnum_classes=cols-5;// 85-5=80类// 第一步:解码+过滤低置信度框for(inti=0;i<rows;++i){float*pitem=predict+i*cols;// 当前预测框的首地址floatobjness=pitem[4];// 目标置信度if(objness<confidence_threshold)// 过滤低obj框,减少后续计算continue;// 找概率最高的类别float*pclass=pitem+5;intlabel=std::max_element(pclass,pclass+num_classes)-pclass;floatprob=pclass[label];floatconfidence=prob*objness;// 最终置信度if(confidence<confidence_threshold)// 二次过滤continue;// 把cx/cy/w/h转成左上/右下坐标floatcx=pitem[0];floatcy=pitem[1];floatwidth=pitem[2];floatheight=pitem[3];floatleft=cx-width*0.5;floattop=cy-height*0.5;floatright=cx+width*0.5;floatbottom=cy+height*0.5;boxes.emplace_back(left,top,right,bottom,confidence,(float)label);}// 第二步:NMS去重// 1. 按置信度降序排序std::sort(boxes.begin(),boxes.end(),[](Box&a,Box&b){returna.confidence>b.confidence;});std::vector<bool>remove_flags(boxes.size(),false);// 标记是否要移除std::vector<Box>box_result;box_result.reserve(boxes.size());// 预分配内存,提升性能// 定义IoU计算函数:计算两个框的重叠度autoiou=[](constBox&a,constBox&b){floatcross_left=std::max(a.left,b.left);floatcross_top=std::max(a.top,b.top);floatcross_right=std::min(a.right,b.right);floatcross_bottom=std::min(a.bottom,b.bottom);floatcross_area=std::max(0.0f,cross_right-cross_left)*std::max(0.0f,cross_bottom-cross_top);// 重叠面积floata_area=(a.right-a.left)*(a.bottom-a.top);floatb_area=(b.right-b.left)*(b.bottom-b.top);floatunion_area=a_area+b_area-cross_area;// 并集面积returnunion_area==0?0:cross_area/union_area;// IoU=重叠面积/并集面积};// 2. 遍历框,标记重复框for(inti=0;i<boxes.size();++i){if(remove_flags[i])continue;// 已标记移除,跳过auto&ibox=boxes[i];box_result.emplace_back(ibox);// 保留当前最高置信度框for(intj=i+1;j<boxes.size();++j){if(remove_flags[j])continue;auto&jbox=boxes[j];if(ibox.label==jbox.label&&iou(ibox,jbox)>=nms_threshold){remove_flags[j]=true;// 同类+高IoU,标记移除}}}returnbox_result;}3.2.2 关键解读(新手必懂)
- 性能优化点(笔记重点):
- 提前过滤:先过滤
objness低的框,避免后续计算类别概率、坐标转换(“先筛掉明显不合格的,再细看”); - 预分配内存:
box_result.reserve(boxes.size())——避免vector频繁扩容,提升速度; - 标志位去重:用
remove_flags标记要移除的框,而非直接删除(删除会导致数组移位,效率低)。
- 提前过滤:先过滤
- 核心逻辑总结:
- 解码:逐框算置信度→过滤→转坐标;
- NMS:排序→逐框对比IoU→标记重复框→保留未标记的框。
3.3 GPU_decode:后处理的“流水线工厂版”
GPU版的核心是「并行处理每个预测框」——启动和预测框数量相等的线程,每个线程处理一个框的解码,再用并行NMS去重。
3.3.1 第一步:decode_kernel(解码核函数)
每个线程负责一个预测框的解码,核心解决“并发写入结果”的问题(用原子操作atomicAdd)。
核心代码
// 定义常量:每个检测框保存的元素数(left,top,right,bottom,confidence,label,keepflag)constintNUM_BOX_ELEMENT=7;constintMAX_OBJECTS=1000;// 最大检测框数量static__global__voiddecode_kernel(float*predict,intnum_bboxes,intnum_classes,floatconfidence_threshold,float*invert_affine_matrix,float*parray,intmax_objects,intNUM_BOX_ELEMENT){// 1. 计算当前线程的全局索引(对应第几个预测框)intposition=blockDim.x*blockIdx.x+threadIdx.x;if(position>=num_bboxes)return;// 超出预测框数量,退出// 2. 取当前预测框的首地址float*pitem=predict+(5+num_classes)*position;floatobjectness=pitem[4];if(objectness<confidence_threshold)return;// 3. 找概率最高的类别float*class_confidence=pitem+5;floatconfidence=*class_confidence++;intlabel=0;for(inti=1;i<num_classes;++i,++class_confidence){if(*class_confidence>confidence){confidence=*class_confidence;label=i;}}// 4. 计算最终置信度,二次过滤confidence*=objectness;if(confidence<confidence_threshold)return;// 5. 原子操作:获取当前框的结果索引(解决并发写入冲突)intindex=atomicAdd(parray,1);// parray[0]存框的数量,原子加1返回旧值if(index>=max_objects)return;// 超过最大框数,退出// 6. 转换坐标floatcx=*pitem++;floatcy=*pitem++;floatwidth=*pitem++;floatheight=*pitem++;floatleft=cx-width*0.5f;floattop=cy-height*0.5f;floatright=cx+width*0.5f;floatbottom=cy+height*0.5f;// 7. 保存结果:parray[1 + index*7]开始存当前框的信息float*pout_item=parray+1+index*NUM_BOX_ELEMENT;*pout_item++=left;*pout_item++=top;*pout_item++=right;*pout_item++=bottom;*pout_item++=confidence;*pout_item++=label;*pout_item++=1;// 1=保留,0=移除}关键解读(新手必懂)
- 线程索引:
position = blockDim.x * blockIdx.x + threadIdx.x——每个线程对应一个预测框,比如num_bboxes=2000,就启动2000个线程; - 原子操作
atomicAdd:parray是GPU上的数组,格式为[count, box1, box2, ...](第一个元素存有效框数量,后面存框信息);atomicAdd(parray, 1):多个线程同时写parray[0]时,保证“加1”操作不冲突(比如线程A和B同时加1,结果是2而不是1);index是当前框在结果数组中的位置,比如parray[0]初始是0,线程A执行后index=0,parray[0]变成1;线程B执行后index=1,parray[0]变成2;
- 和CPU版的区别:
- 不用逐框循环(CPU是for循环,GPU是并行线程);
- 结果保存到连续数组,而非vector(GPU上用数组更高效);
- 暂时不做NMS,先解码保存所有有效框。
3.3.2 第二步:fast_nms_kernel(并行NMS核函数)
GPU版NMS不用排序(CPU版要排序),直接并行对比每个框和其他框的IoU,核心是“快但极端情况可能少框”。
核心代码
// 先定义IoU计算函数(GPU版)__device__floatbox_iou(floatleft1,floattop1,floatright1,floatbottom1,floatleft2,floattop2,floatright2,floatbottom2){floatcross_left=max(left1,left2);floatcross_top=max(top1,top2);floatcross_right=min(right1,right2);floatcross_bottom=min(bottom1,bottom2);floatcross_area=max(0.0f,cross_right-cross_left)*max(0.0f,cross_bottom-cross_top);floatarea1=max(0.0f,right1-left1)*max(0.0f,bottom1-top1);floatarea2=max(0.0f,right2-left2)*max(0.0f,bottom2-top2);floatunion_area=area1+area2-cross_area;returnunion_area==0?0:cross_area/union_area;}static__global__voidfast_nms_kernel(float*bboxes,intmax_objects,floatthreshold,intNUM_BOX_ELEMENT){// 1. 计算当前线程索引(对应第几个有效框)intposition=blockDim.x*blockIdx.x+threadIdx.x;intcount=min((int)*bboxes,max_objects);// 有效框数量if(position>=count)return;// 2. 取当前框的信息float*pcurrent=bboxes+1+position*NUM_BOX_ELEMENT;for(inti=0;i<count;++i){float*pitem=bboxes+1+i*NUM_BOX_ELEMENT;if(i==position||pcurrent[5]!=pitem[5])continue;// 跳过自己/不同类的框// 3. 对比置信度:如果其他框置信度更高,计算IoUif(pitem[4]>=pcurrent[4]){if(pitem[4]==pcurrent[4]&&i<position)continue;// 置信度相同,保留索引小的floatiou=box_iou(pcurrent[0],pcurrent[1],pcurrent[2],pcurrent[3],pitem[0],pitem[1],pitem[2],pitem[3]);if(iou>threshold){pcurrent[6]=0;// 标记为移除return;}}}}关键解读(新手必懂)
- fast NMS的核心逻辑:
- 每个线程处理一个有效框,对比它和所有其他框;
- 如果存在“同类别+置信度更高+IoU超阈值”的框,就标记当前框为移除(
pcurrent[6]=0); - 不用排序,直接并行对比,速度比CPU版快10倍以上;
- 为什么极端情况少框?
- 比如有3个重叠框,置信度都是0.8,线程处理顺序不同可能导致全部被标记移除;
- 实测中这种情况极少,不影响实际使用;
- mAP测试要用人CPU NMS的原因:
- mAP(平均精度)需要精确的框筛选结果,GPU版fast NMS的“少量漏框”会影响精度计算;
- 实际部署时用GPU版(快),测试精度时用CPU版(准)。
3.3.3 GPU_decode的完整流程
// 伪代码:GPU后处理的整体调用vector<Box>gpu_decode(float*predict_cpu,introws,intcols){// 1. CPU→GPU拷贝模型输出float*predict_gpu=nullptr;checkRuntime(cudaMalloc(&predict_gpu,rows*cols*sizeof(float)));checkRuntime(cudaMemcpy(predict_gpu,predict_cpu,rows*cols*sizeof(float),cudaMemcpyHostToDevice));// 2. GPU分配结果数组([count, box1, box2,...])float*parray_gpu=nullptr;intparray_size=1+MAX_OBJECTS*NUM_BOX_ELEMENT;checkRuntime(cudaMalloc(&parray_gpu,parray_size*sizeof(float)));checkRuntime(cudaMemset(parray_gpu,0,parray_size*sizeof(float)));// 初始化count=0// 3. 启动解码核函数dim3block_size(256);// 每个block256个线程dim3grid_size((rows+255)/256);// 向上取整decode_kernel<<<grid_size,block_size>>>(predict_gpu,rows,cols-5,0.25f,nullptr,parray_gpu,MAX_OBJECTS,NUM_BOX_ELEMENT);// 4. 启动fast NMS核函数intcount=min((int)*parray_gpu,MAX_OBJECTS);grid_size=(count+255)/256;fast_nms_kernel<<<grid_size,block_size>>>(parray_gpu,MAX_OBJECTS,0.45f,NUM_BOX_ELEMENT);// 5. GPU→CPU拷贝结果,解析成Box结构体float*parray_cpu=newfloat[parray_size];checkRuntime(cudaMemcpy(parray_cpu,parray_gpu,parray_size*sizeof(float),cudaMemcpyDeviceToHost));vector<Box>result;count=min((int)parray_cpu[0],MAX_OBJECTS);for(inti=0;i<count;++i){float*pbox=parray_cpu+1+i*NUM_BOX_ELEMENT;if(pbox[6]==1){// 保留的框result.emplace_back(pbox[0],pbox[1],pbox[2],pbox[3],pbox[4],pbox[5]);}}// 6. 释放内存checkRuntime(cudaFree(predict_gpu));checkRuntime(cudaFree(parray_gpu));delete[]parray_cpu;returnresult;}四、补充知识:笔记里的实战技巧
- 变量控制法(调试神器):
- 用PyTorch推理后,把输出转成numpy→tobytes→保存到文件;
- C++读取文件数据做后处理,不用搭TensorRT环境就能调试,快速对比CPU/GPU结果是否一致;
- GPU后处理的性能优化:
- 线程块大小设为32的倍数(比如256),符合GPU warp(32线程)的执行规则;
- 尽量减少GPU和CPU之间的数据拷贝(解码、NMS都在GPU上做,最后只拷贝结果);
- fast NMS的取舍:
- 部署优先用GPU fast NMS(快),精度测试用CPU标准NMS(准);
- 极端情况漏框的问题,可通过调整NMS阈值(比如从0.45降到0.4)缓解。
图2-3 PyTorch效果
图2-4 自定义实现后处理的效果
五、总结:核心要点回顾
- YOLOv5后处理分两步:解码(过滤+转坐标)+ NMS(去重),CPU版逐框处理,GPU版并行处理每个框;
- GPU解码的核心是
atomicAdd:解决多线程并发写入结果的冲突问题; - GPU NMS用fast NMS:不用排序,并行对比IoU,速度快但极端情况可能少框,部署时性价比极高。
这份案例是YOLOv5 TensorRT部署的核心环节——把预处理(之前的warpAffine)和后处理都放到GPU上,实现“模型推理+前后处理”全GPU加速,这也是高性能部署的关键。