ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

TensorRT+C++部署RetinaFace:实时人脸检测的推理加速与工程实践

TensorRT+C++部署RetinaFace:实时人脸检测的推理加速与工程实践 简介这是一份面向深度学习部署工程师与计算机视觉开发者的优质算法部署项目围绕TensorRTC实现RetinaFace人脸检测的加速推理解决高精度模型在实时场景下的性能瓶颈。项目从MXNet模型转Caffe、生成TensorRT可用的prototxt与caffemodel到INT8校准和TensorRT运行时构建再到预处理、推理、后处理全链路均可直接复现。压缩包共65个文件以C核心源码.cpp/.h/.cu、Python转换与校准脚本.py/.pyc、模型权重.caffemodel/.params/.prototxt以及工程配置CMakeLists/.pro/.json为主其中C源码负责推理与后处理Python脚本完成模型转换与INT8量化prototxt/caffemodel提供模型结构与权重整体仅6.67MB包含完整目录与README便于按模块学习。目前已有70人学习。资源附带INT8校准工具、测试图片和推理示例详细展示了TensorRT的层融合、混精度与动态张量等特性读者可快速掌握从算法模型到高性能生产级部署的完整思路对优化其他检测模型同样具有借鉴价值。1. TensorRTCpp部署retinaface为什么实时人脸检测要上这套组合拳很多人第一次接触TensorRT是在PyTorch里推理已经卡到没法接视频流的时候。retinaface这种多尺度人脸检测模型在Python里跑一帧看起来很快实际上在CPU和GPU之间反复搬运数据、动态建图到了摄像头场景帧率立刻掉下来。我说TensorRTCpp部署retinaface本质是把pt文件转换TensorRT能直接加载的engine然后在C工程里完成从图像输入到人脸框输出的完整链路。这套方案能让单帧推理时间降到个位数毫秒适合门禁抓拍、实时摄制视频的人脸检测与标注这类不能掉链子的场景。接下来把retinaface的输出结构、模型转换、C工程和踩坑过程一次讲清楚。2. 先把retinaface的输出结构掰开三个检测头决定后面所有代码拿到一套模型就想直接转TensorRT结果往往是engine构建成功但解码出来的框全是乱的。问题不在TensorRT在于retinaface的输出层不只有一个人脸框而是多个尺度的多个分支后处理代码必须严格对齐这些输出。2.1 三个检测分支到底输出了什么Retinaface本体是RetinaNet结构加SSH上下文模块主干是ResNet50或者MobileNet0.25实际部署时常见的是ResNet50版本。网络从FPN引出三条特征层经过SSH模块后分别接出三个头box回归、分类、关键点回归。举个例子输入640x640的RGB图stride 8的特征图是80x80stride 16的特征图是40x40stride 32的特征图是20x20每个特征图位置会预设两个anchor所以整张图一共有16800个候选框。输出口因此是9个tensor3个bbox输出每个维度是anchor数x43个cls输出每个维度是anchor数x23个landmark输出每个维度是anchor数x10特征层特征图尺寸anchor数量bbox tensor长度cls tensor长度landmark tensor长度stride 880x80128005120025600128000stride 1640x40320012800640032000stride 3220x20800320016008000导出ONNX时如果发现输出维度和这个表对不上先别往下走说明网络定义和预期不一致。后面C后处理的所有代码都依赖这些tensor的形状和顺序。2.2 anchor先验框与解码公式这里写错一点坐标就会翻车网络输出的不是直接坐标而是相对anchor先验框的偏移量。我们得先有一组固定的anchor列表把偏移量解成真正的框坐标。anchor生成逻辑在不同repo里略有差异常见做法是在特征图的每个位置上生成两个不同尺度的先验框并用特征图步长把坐标归一化到输入尺寸。以stride 8为例特征图80x80每个位置中心坐标是cx (j 0.5) * 8 cy (i 0.5) * 8框的宽高则由预设的min_sizes决定一般RetinaFace ResNet版用的min_sizes是stride 8: [16, 32]stride 16: [64, 128]stride 32: [256, 512]最后把anchor的坐标再除以输入尺寸归一化到0到1之间。我们把所有尺度的anchor拼成一维数组总长度就是16800x4。拼接顺序必须和ONNX导出的输出顺序一致通常是从stride 8到stride 32但也有repo会反过来。如果出现框位置全部错乱优先检查这一步。解码公式是部署里最容易翻车的地方。常见写作struct Anchor { float cx, cy, w, h; }; void decode_bbox(const float* loc, const Anchor a, float variance[2], float* box) { // loc是网络输出的x,y,w,h偏移 box[0] a.cx loc[0] * variance[0] * a.w; box[1] a.cy loc[1] * variance[0] * a.h; box[2] a.w * expf(loc[2] * variance[1]); box[3] a.h * expf(loc[3] * variance[1]); }variance一般取0.1和0.2这个值不是随便拍的而是训练时固定下来的。有些项目把loc的四个值含义定义成中心点偏移和宽高比例但实际推理用的都是上述乘variance再解码的方式。关键点landmark也走同一套公式只是loc对应的是10个偏移值解码结果就是5个点的坐标。2.3 后处理的正确顺序先置信度过滤再做NMS拿到解码后的坐标直接画框会把置信度很低的框也画出来。常见处理顺序是先把所有16800个候选框的class分数取出来对人脸那一类的分数做阈值过滤比如0.5得到几百个候选框再做NMS去掉重复框。cls输出没有经过softmax这也是能直接过滤的原因。很多推理框架对二分类输出只取第二维作为正类分数不额外做softmax在TensorRT里少一层算子速度还能更快。如果你的原版PyTorch代码里对cls做了softmax那导出ONNX时也必须保留否则阈值语义就变了。NMS这一步放到CPU做没什么问题人脸场景候选框经过置信度过滤后通常只剩几十个CPU上的NMS耗时可以忽略。GPU上的全局NMS反而容易因不同block之间的同步问题出现漏框。我一般把解码和NMS都放在CPU后处理里省去在TensorRT里拼插件的复杂度。3. 模型转换从pt文件转TensorRT的完整链路与参数搭配RetinaFace转TensorRT不推荐直接在PyTorch里用torch2trt这类钩子式工具一是控制不了输出层顺序二是遇到自定义Sigmoid、FPN后处理时容易炸。最稳的路线是先把pt导出成ONNX再用TensorRT自带的trtexec生成engine最后C里只加载engine。3.1 第一步PyTorch模型导出成ONNX注意固定输入尺寸导出前先把模型切成eval模式并准备好一个固定尺寸的dummy输入。如果模型内部有控制流依赖batch size的代码TensorRT会很难优化所以部署用模型最好在导出前就固定batch和分辨率。import torch from models.retinaface import RetinaFace model RetinaFace(cfg) checkpoint torch.load(retinaface_resnet50.pth, map_locationcpu) model.load_state_dict(checkpoint[state_dict]) model.eval() dummy torch.zeros(1, 3, 640, 640) torch.onnx.export( model, dummy, retinaface.onnx, opset_version11, input_names[input], output_names[ bbox_0, bbox_1, bbox_2, cls_0, cls_1, cls_2, landmark_0, landmark_1, landmark_2, ], )这里的关键是output_names的顺序。建议在导出前先跑一次model打印输出的shape顺序再和onnx导出文件里的输出名一一对应。有些repo的输出是[loc, conf, land]有些是分开的list导出时多花五分钟确认能省掉后面排查的一上午。导出后最好用onnxruntime跑一遍确认ONNX的输出和PyTorch一致。这一步别省很多问题到TensorRT阶段才暴露根本分不清是转换问题还是部署问题。3.2 第二步用trtexec生成TensorRT engine几个参数是关键TensorRT安装教程网上已经很多我只提醒一个点TensorRT版本、CUDA小版本、显卡算力三者必须匹配。装好之后直接用trtexec生成enginetrtexec \ --onnxretinaface.onnx \ --saveEngineretinaface_fp32.trt \ --workspace4096 \ --verboseworkspace控制TensorRT在构建阶段能用的显存上限单位是MB设置4096对ResNet50这种规模够用。verbose可以看算子融合的具体日志排查哪个层被标记为不支持。普通单路摄像头场景固定shape的engine就够了不需要动态batch。如果确认要用FP16加上--fp16trtexec \ --onnxretinaface.onnx \ --saveEngineretinaface_fp16.trt \ --fp16 \ --workspace4096加--fp16之后TensorRT会尽量把所有层转成FP16但不是所有算子都支持部分层会保留FP32。trtexec跑完会打印每层的精度策略和总构建时间如果发现卷积层全是FP32就去检查CUDA版本是不是太老。3.3 fp16还是fp32什么时候可以把精度让给速度这个选择直接影响落地效果。用一张表说清楚模式速度小脸召回适用场景fp32基准约80-110 fps最好精度优先离线分析fp16提升30%-50%小脸可能漏检实时视频流门禁int8最快需要校准集召回明显下降硬核性能优先我的经验是如果项目刚起步先用fp32跑通整个pipeline确认坐标解码、后处理都正确再切fp16。不要上来就fp16否则翻车时你都不知道是模型问题还是精度模式问题。对于人脸框级别的人脸位置fp16精度损失其实能接受但关键点坐标会有轻微抖动实时美颜标注类项目尤其明显。小脸密集型场景就老实留在fp32。注意engine文件跟显卡型号、TensorRT版本、CUDA驱动强绑定。换机器必须重新用trtexec生成engine不能直接把.trt拷走。4. C推理工程落地加载engine、预处理、后处理一站式代码这里给一套能跑通的C工程骨架。不做花哨设计目标是让读者能照着自己项目改。4.1 项目骨架和engine加载项目文件不需要多复杂main.cpp读取视频/图片调用推理接口retinaface.h / retinaface.cpp加载engine、预处理、推理、后处理CMakeLists.txt链接TensorRT和OpenCV加载engine的代码是最容易踩版本的先看TensorRT 8.x写法#include fstream #include vector #include NvInfer.h class Logger : public nvinfer1::ILogger { void log(Severity severity, const char* msg) noexcept override { if (severity Severity::kWARNING) { fprintf(stderr, [TRT] %s\n, msg); } } }; static Logger gLogger; std::shared_ptrnvinfer1::ICudaEngine loadEngine(const std::string path) { std::ifstream file(path, std::ios::binary); if (!file.good()) { printf(engine file not found: %s\n, path.c_str()); return nullptr; } file.seekg(0, std::ios::end); size_t size file.tellg(); file.seekg(0, std::ios::beg); std::vectorchar data(size); file.read(data.data(), size); file.close(); nvinfer1::IRuntime* runtime nvinfer1::createInferRuntime(gLogger); nvinfer1::ICudaEngine* engine runtime-deserializeCudaEngine(data.data(), size); return std::shared_ptrnvinfer1::ICudaEngine(engine, [runtime](nvinfer1::ICudaEngine* e) { e-destroy(); runtime-destroy(); }); }这段代码的核心是deserializeCudaEngine它把前面trtexec生成的engine二进制直接加载进显存。engine文件里已经包含网络结构和权重C这边不需要再碰PyTorch和ONNX。4.2 图像预处理像素布局与归一化必须对齐训练RetinaFace训练时最常见的数据预处理是把图缩放到640x640然后每个像素减127.5再除128。注意要先转成RGBTensorRT里没有OpenCV那套BGR习惯通道顺序错了整个检测框会往一处偏移。cv::Mat preprocessImage(cv::Mat bgr, int input_w, int input_h, float* host_buffer) { cv::Mat rgb, resized; cv::cvtColor(bgr, rgb, cv::COLOR_BGR2RGB); cv::resize(rgb, resized, cv::Size(input_w, input_h), 0, 0, cv::INTER_LINEAR); // RGB - NCHW并做 (x - 127.5) / 128 归一化 int channels 3; for (int c 0; c channels; c) { for (int h 0; h input_h; h) { for (int w 0; w input_w; w) { float pixel resized.atcv::Vec3b(h, w)[c]; host_buffer[c * input_h * input_w h * input_w w] (pixel - 127.5f) / 128.0f; } } } return resized; }这里不要偷懒直接用blobFromImage的默认参数因为blobFromImage的scale和mean/std语义跟上面不同。确定归一化式子的唯一依据是看训练代码或模型作者给的预处理脚本最常见的就是这个127.5和128的组合。推理前的显存拷贝cudaMemcpyAsync( d_input, host_buffer, input_size * sizeof(float), cudaMemcpyHostToDevice, stream);cudaMemcpyAsync必须放到stream上这样可以和后续算子重叠才能把CPU预处理和GPU推理并行起来。4.3 后处理与坐标还原拿到人脸框、五点和置信度推理调用之后输出tensor都在显存里逐个拷贝回CPU。这里有一个效率点不要每帧都cudaMemcpy全部输出而是在初始化时就分配好输出buffer每次推理后在同一个buffer上覆盖。// 拿到各输出tensor的地址 void* buffers[] {d_input, d_bbox_0, d_bbox_1, d_bbox_2, d_cls_0, d_cls_1, d_cls_2, d_landmark_0, d_landmark_1, d_landmark_2}; context-enqueueV2(buffers, stream, nullptr); cudaStreamSynchronize(stream); // 拷贝结果回CPU cudaMemcpy(host_bbox_0, d_bbox_0, bbox_0_size * sizeof(float), cudaMemcpyDeviceToHost); // 每个输出都拷贝一次后处理先做阈值过滤再解码坐标。顺序不能反先解码再过滤虽然也行但会把16800个候选全部解一遍浪费CPU时间。std::vectorFaceBox postprocess( const float* bbox0, const float* bbox1, const float* bbox2, const float* cls0, const float* cls1, const float* cls2, const float* land0, const float* land1, const float* land2, const std::vectorAnchor anchors, float conf_thresh, float nms_thresh) { std::vectorFaceBox rawBoxes; int offsets[3] {0, 12800, 16000}; // 12800 3200 // 三个stride分头处理 for (int layer 0; layer 3; layer) { int num_anchors (layer 0) ? 12800 : (layer 1 ? 3200 : 800); const float* bbox_ptr (layer 0) ? bbox0 : (layer 1 ? bbox1 : bbox2); const float* cls_ptr (layer 0) ? cls0 : (layer 1 ? cls1 : cls2); const float* land_ptr (layer 0) ? land0 : (layer 1 ? land1 : land2); for (int i 0; i num_anchors; i) { float score cls_ptr[i * 2 1]; // 第1维是人脸 if (score conf_thresh) continue; FaceBox box; decode_bbox(bbox_ptr i * 4, anchors[offsets[layer] i], variance, box.box); decode_landmark(land_ptr i * 10, anchors[offsets[layer] i], variance, box.keypoints); box.score score; rawBoxes.push_back(box); } } // CPU NMS人脸框数量不大性能足够 std::vectorint indices; std::vectorcv::Rect cvBoxes; std::vectorfloat scores; for (auto b : rawBoxes) { int x (int)(b.box[0] * input_w); int y (int)(b.box[1] * input_h); int w (int)((b.box[2] - b.box[0]) * input_w); int h (int)((b.box[3] - b.box[1]) * input_h); cvBoxes.push_back(cv::Rect(x, y, w, h)); scores.push_back(b.score); } cv::dnn::NMSBoxes(cvBoxes, scores, conf_thresh, nms_thresh, indices); std::vectorFaceBox result; for (int idx : indices) result.push_back(rawBoxes[idx]); return result; }这段逻辑重点在offsets数组里记录的anchor排列顺序。stride 8有12800个anchorstride 16从12800开始stride 32从16000开始。如果你的模型输出顺序不是这个排列直接表现为检测框位置整体错乱但置信度正常那就调整offsets的拼接顺序。5. TensorRT部署retinaface的常见坑5个排查实例5.1 现象engine加载失败报“incompatible with current TensorRT version”原因很简单engine文件是在另一台机器上用不同TensorRT版本生成的或者显卡算力不匹配。TensorRT 10.x对老显卡的优化分支已经收窄GTX 1070这种Pascal卡不同版本的体验差别很大。解决别跨环境复制engine文件在自己的目标机器上重新用trtexec构建。如果TRT 10.x在Pascal上构建慢或失败换个老版本TensorRT 8.5.x并把驱动升到支持该版本CUDA的较新版本。engine文件不是模型文件它是和硬件绑定的可执行产物每换一次环境就要重新构建。5.2 现象推理正常但检测框整体偏移置信度也不低如果是框的位置向同一方向偏移或者框的大小整体偏大偏小第一个怀疑预处理。通道顺序错会导致人脸位置偏但不会漂移太离谱真正离谱的偏移多半是归一化公式不对。训练用0到1归一化推理用0到255减均值除方差数值范围错几倍输出置信度会异常。解决先拿一张测试图在Python里用ONNX Runtime跑一次同一个ONNX打印输入矩阵的均值和输出分数再变成对比C这边的情况。逐层对比下来问题五分钟就定位。5.3 现象输出全为0模型完全失效这种通常是输入数据根本没有进入显存。常见原因是buffer地址传错比如输入buffer分配的是NCHW大小但预处理时按CHW连续方式填了数据之后又把TensorRT的binding index搞混了。解决打印每个binding的name和dims和预期输出表比一遍。TensorRT API里getBindingIndex一定要在engine加载后马上调用存成成员变量不要在每帧推理时才动态查。5.4 现象fp16模式下小脸大面积漏检FP16的精度范围对小数值的梯度表达没有FP32细腻RetinaFace的第二层和第三层特征对小脸响应本来就不高FP16直接把这些响应压成了噪声。解决如果项目以远处行人、监控画面为主不要用fp16。如果非要提速先试试只在后两个特征层保留fp16或者做量化感知训练再转int8。别指望靠换TensorRT版本解决精度问题精度问题出在前向计算的数值范围上。5.5 现象摄像头场景下跑几分钟显存持续增长这是典型的显存泄漏。每帧推理都新建cudaStream或者用cudaMalloc给输出分配临时buffer却不释放。TensorRT本身会在引擎加载时分配显存这部分是固定的如果显存持续增长问题一般出在调用侧。解决把cudaStreamCreate挪到初始化时执行一次所有输出buffer也一次性分配好整个推理循环里不new、不cudaMalloc只做cudaMemcpy和同步。排查时在每帧后调用cudaGetMemInfo对比显存变化很好定位。6. 跑通实时视频流后我建议你接着做这几件事到了这一步静态图片推理已经稳定接下来要接摄像头。常见的主循环长这样cv::VideoCapture cap(0); if (!cap.isOpened()) return -1; // warm upGPU首次推理需要编译和初始化跳过前20帧 for (int i 0; i 20; i) { cv::Mat frame; cap frame; runInference(frame); } while (true) { cv::Mat frame; cap frame; if (frame.empty()) break; auto t0 cv::getTickCount(); auto faces runInference(frame); drawFaceBoxes(frame, faces); auto t1 cv::getTickCount(); double fps cv::getTickFrequency() / (t1 - t0); printf(fps: %.1f\n, fps); cv::imshow(retinaface demo, frame); if (cv::waitKey(1) 27) break; }视频流和静态图片有个本质区别摄像头画面的帧率是固定的如果推理耗时超过帧间隔queue里会积压旧帧导致画面越来越卡。常见做法是主循环里读到新帧后丢弃上一帧未处理完的数据只处理最新帧保证实时性。最后说一个我自己的验证习惯。项目完工前我会把同一张测试图分别用Python原模型和C部署各跑一次比对5个关键点坐标和两个最大的检测框。坐标差值在2个像素以内才算真正对齐。这也是我推荐的验收标准——不看总帧率只看单帧结果是否和原始PyTorch一致。把这条守住了后续项目里再遇到奇怪问题基本都能定位到某一个步骤而不是整个链路。希望帮到你。本文还有配套的精品资源点击获取
返回列表