ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

OpenCV部署YOLOv8人脸关键点模型:ONNX导出与后处理全流程解析

OpenCV部署YOLOv8人脸关键点模型:ONNX导出与后处理全流程解析 简介基于OpenCV部署YOLOv8的人脸检测与关键点检测项目代码完整可用提供Python和C两版可直接运行的源码并面向车牌四角点检测预留替换接口适合计算机视觉入门进阶、课程设计、大作业及毕业设计参考。资源包共10个文件包含3个不同规格的ONNX推理模型、Python与C主程序、说明文档、测试图片及源码备份压缩包整体约24.17MB结构清晰便于按目录快速定位。项目已通过功能验证可稳定运行目前已有1685人学习下载。读者可结合说明文档理解OpenCV调用ONNX模型的完整流程在不同尺寸的轻量级网络间权衡速度与精度进一步可将关键点输出改为车牌四角点用于车牌定位、文档校正等场景也可基于现有代码调整输入输出适配自定义检测目标二次开发空间充足。1. 用OpenCV吃下YOLOv8的人脸关键点模型为什么后处理比推理更难把YOLOv8的人脸检测关键点检测模型部署到OpenCV里最花时间的从来不是readNetFromONNX那一行而是你盯着一个 1×21×8400 的数组不知道怎么把它变回一张带点和框的图。这个标题对应的工作其实很实在模型导出成ONNX交给OpenCV的DNN模块做推理同时交付Python和C两套代码并且把关键点数量做成可配置从人脸的5个点换成车牌的4个角点只改一处。适合的场景也很明确不想在生产环境引入PyTorch依赖、要在C里交付功能、或者只想快速做个不依赖GPU的POC。下面我按一条能跑通的路来拆解从模型结构一路写到换模型的最小改动。2. 模型导出与输出结构ONNX的21通道里到底装了什么2.1 输出通道怎么排的从检测头到关键点回归YOLOv8的人脸关键点模型和我们常见的纯检测模型在输出层上有一处决定性的差异检测框和关键点是同一个头里出来的不是两个模型。一个 batch 的输入图片经过backbone和neck之后会得到三个尺度80×80、40×40、20×20的特征图对应步长 8、16、32。每个尺度的每个格子负责预测一个候选框以及这个框对应的关键点坐标。三个尺度加起来就是 80×8040×4020×20 8400 个候选位置。每个候选位置输出的通道数是这样算出来的前4个通道是框的中心点坐标和宽高cx、cy、w、h第5个通道是框的置信度第6个通道是类别置信度人脸检测通常只有1类从第7个通道开始是5个关键点每个关键点3个通道x、y、可见度visible。4115×3 21这就是21通道的来历。这里还有一个很关键的约定模型导出时框和关键点坐标已经完成了解码直接以输入图的像素为单位给出比如输入是640×640那坐标范围大致在0到640之间不再需要我们在后处理里做DFL解码和步长换算。这也是OpenCV部署YOLOv8相对省事的地方——真正麻烦的是把坐标还原到原图。通道区间内容说明03cx, cy, w, h中心点宽高输入图640尺度4box_conf是否包含目标5class_conf人脸/车牌单类场景6205个关键点×3通道每人脸点输出x、y、visible如果换成车牌4角点模型通道数会变成 4114×3 18。后处理代码里几乎所有逻辑都不变只有关键点数量这个常量在变。2.2 导出ONNX的命令与OpenCV算子兼容性模型从训练框架导出成ONNX时需要特别注意算子兼容性。OpenCV的DNN模块对ONNX算子的支持是有限的ONNX的文件格式再标准只要里面出现OpenCV不认的算子readNetFromONNX就会直接抛异常。最常见的组合是导出时指定opset12同时把模型里那些动态shape相关的结构简化掉。yolo export modelface_keypoint.pt formatonnx opset12 simplifyTrue imgsz640这条命令的意思是把训练好的模型导出成ONNX格式opset用12OpenCV支持得最稳的版本区间13和14部分版本也能跑但没必要冒险simplifyTrue 表示导出后用简化工具梳理一遍计算图去掉冗余节点。imgsz640 把输入尺寸锁死这会让导出的模型输入形状固定为 (1, 3, 640, 640)对OpenCV这种不支持动态shape推理的模块最友好。导出时千万别加end2endTrue这种选项。加了会让模型内部包含NMS节点很多OpenCV版本拿到这种图要么加载失败要么推理结果格式完全不可控。我们后处理里自己写NMS就三五行代码没必要让模型把这个活干了。2.3 验证输出形状先打印后写代码我习惯在写任何后处理代码之前先用一个三分钟的脚本把导出模型的输出shape打出来。这个动作能避开后面大量“索引越界”和“画出来的点飞了”的玄学问题。import onnx model onnx.load(face_keypoint.onnx) for out in model.graph.output: print(out.name) for dim in out.type.tensor_type.shape.dim: print(dim.dim_value, end ) print()这段代码就是读取ONNX文件打印每个输出节点的维度和形状。正常情况下你会看到输出维度是 1 21 8400 或者 1 8400 21 这两种形态中的一种。前者是通道在前channels-first后者是候选在前channels-last。这两种排布在后处理里的索引写法完全不同必须提前知道。注意这里打印出来的 shape 是模型定义层面的。OpenCV的net.forward()返回的 Mat 会基本保持这个顺序但个别版本对二维和三维的排布有微调最后以运行时打出来的out.shape为准。如果输出显示的不是上面两个形状之一先检查是不是导出时加了奇怪的后处理节点。最常见的翻车是输出多了一个NMS节点shape 变成了 1 100 7这种图不适合OpenCV部署按 2.2 的导出命令重新导一次更省时间。3. Python版手把手letterbox、候选解析与NMS全流程3.1 预处理letterbox和blobFromImage模型要的输入是 640×640 的正方形图但摄像头拍出来的图几乎都是长方形的。直接拉伸会改变人脸比例导致检测框和关键点偏掉所以要用letterbox的方式先按比例缩放让长边或短边贴近640然后给四周补灰边把图填成正方形。import cv2 import numpy as np def letterbox(img, new_shape(640, 640), color(114, 114, 114)): h, w img.shape[:2] ratio min(new_shape[0] / h, new_shape[1] / w) new_unpad_w, new_unpad_h int(round(w * ratio)), int(round(h * ratio)) dw (new_shape[1] - new_unpad_w) / 2 dh (new_shape[0] - new_unpad_h) / 2 if (w, h) ! (new_unpad_w, new_unpad_h): img cv2.resize(img, (new_unpad_w, new_unpad_h), interpolationcv2.INTER_LINEAR) top, bottom int(round(dh - 0.1)), int(round(dh 0.1)) left, right int(round(dw - 0.1)), int(round(dw 0.1)) img cv2.copyMakeBorder(img, top, bottom, left, right, cv2.BORDER_CONSTANT, valuecolor) return img, ratio, (dw, dh)这个函数返回三个东西填充后的640图、缩放比例 ratio、以及每个边补了多少像素 dw 和 dh。后面还原坐标全靠这三个值。调用它之后再用blobFromImage把它转成模型需要的输入张量。注意scalefactor1/255.0这一步模型训练时图通常是归一化到0到1区间的不做除法等于输入范围错了一个数量级检测结果会非常不稳定。img_640, ratio, (dw, dh) letterbox(original_img) blob cv2.dnn.blobFromImage(img_640, scalefactor1/255.0, size(640, 640), mean(0, 0, 0), swapRBTrue, cropFalse) net.setInput(blob) outs net.forward() print(outs.shape) # 期望看到 (1, 21, 8400) 或 (1, 8400, 21)swapRBTrue是因为模型训练时用的是RGB顺序而OpenCV读图默认是BGR。如果你的训练框架也是BGR这个参数设成False即可。用这个习惯我建议在代码里加一行注释标明模型的通道顺序否则换模型的时候经常会在这里翻车。3.2 候选解析从8400个位置里筛出人脸拿到outs之后要做的第一件事是把它理解成一张表格8400行每行21个数字。接下来就是常规的坐标解码和置信度过滤。以下代码以outs.shape (1, 21, 8400)这种channels-first排布为例。conf_thres, iou_thres 0.25, 0.45 rows outs.shape[2] # 8400 num_kpts 5 # 人脸关键点数 kpt_start 6 # 关键点从第6个通道开始 boxes, scores, kpts_list [], [], [] for i in range(rows): box_conf float(outs[0, 4, i]) cls_conf float(outs[0, 5, i]) score box_conf * cls_conf if score conf_thres: continue cx, cy, w, h (float(outs[0, j, i]) for j in range(4)) x1, y1 cx - w / 2, cy - h / 2 x2, y2 cx w / 2, cy h / 2 # 还原到原图坐标 x1 (x1 - dw) / ratio y1 (y1 - dh) / ratio x2 (x2 - dw) / ratio y2 (y2 - dh) / ratio boxes.append([x1, y1, x2, y2]) scores.append(score) kpts [] for k in range(num_kpts): kx float(outs[0, kpt_start k * 3, i]) ky float(outs[0, kpt_start k * 3 1, i]) visible float(outs[0, kpt_start k * 3 2, i]) kx (kx - dw) / ratio ky (ky - dh) / ratio kpts.append((int(kx), int(ky), visible)) kpts_list.append(kpts)这段代码的逻辑分三步。第一步算置信度用框置信度乘类别置信度得到这个候选最终的可信程度低于conf_thres的直接跳过省得后面NMS处理一堆垃圾候选。第二步取前4个通道解码出边框坐标这里必须清楚模型输出的是中心点加宽高要自己转成左上角和右下角。第三步是关键点还原对每个关键点取3个通道x、y同样要减去pad再除以缩放比例visible值保留下来画图时可以决定这个点要不要显示。这里有一个容易踩的细节dw和dh是640尺度上的填充像素数而ratio是原图到640的缩放比例。还原公式是(x_640 - pad) / ratio顺序不能反。如果先除再减坐标会整体偏移人脸框会比实际位置偏向右下角。3.3 NMS与可视化把框和点画回原图候选列表里同一个脸可能被相邻格子预测出好几个框需要NMS合并。OpenCV的cv2.dnn.NMSBoxes可以直接用但返回值格式在不同版本里变化过做一层兼容处理能省不少心。if len(boxes) 0: indices cv2.dnn.NMSBoxes(boxes, scores, conf_thres, iou_thres) if len(indices) 0: indices np.array(indices).reshape(-1) else: indices [] for idx in indices: x1, y1, x2, y2 [int(v) for v in boxes[idx]] cv2.rectangle(original_img, (x1, y1), (x2, y2), (0, 255, 0), 2) for kx, ky, visible in kpts_list[idx]: if visible 0.5: continue cv2.circle(original_img, (kx, ky), 3, (0, 0, 255), -1)NMSBoxes的输入是boxes列表此时是xyxy格式、scores列表、置信度阈值和IoU阈值。两个阈值的调整逻辑是conf越低召回越多但误检也会上来人脸密集的场景把conf放到0.15一般场景0.25体感比较平衡iou越大重叠框越不容易被消除两个人脸离得近的时候如果iou设0.7可能出现一个脸画两个框的情况0.45到0.5之间比较稳妥。参数推荐值调试方向conf_thres0.25漏检多就调低误检多就调高iou_thres0.45脸挨得近还叠框就调低到0.3~0.4input_size640追求速度改320追求小脸召回改1280visible 这个值在不同模型里的含义略有差异。人脸模型里它表示模型认为这个点是否可见阈值用0.5作为默认分界。如果你的模型训练时压根没输出visible那这3个通道里的第3个可能会是常量0或1原样画点也不影响效果。4. C版移植Mat布局、手动索引与参数化配置4.1 工程结构和CMake配置C版本和Python版本在算法流程上完全一致差的只是OpenCV的API风格和内存访问方式。工程只需要三个文件一个CMakeLists.txt、一个main.cpp、一个model的onnx文件放在同级目录或者指定路径。cmake_minimum_required(VERSION 3.16) project(face_landmark_demo) find_package(OpenCV REQUIRED COMPONENTS core imgproc dnn) add_executable(demo main.cpp) target_link_libraries(demo ${OpenCV_LIBS}) target_compile_features(demo PRIVATE cxx_std_17)这段CMake配置干了两件事用find_package找到OpenCV的 core、imgproc、dnn 三个组件其中dnn是必须的——核心的readNetFromONNX和net.forward()都在这个模块里然后把 main.cpp 编译成可执行文件。如果你的OpenCV是编译了CUDA支持的版本后面还可以加一行DNN_TARGET_CUDA的推理设置但CPU部署不需要。编译指令在Linux下一般是mkdir build cd build cmake .. makeWindows下用CMakeGUI或者VS的CMake工程打开这个目录都可以。4.2 核心差异三维Mat的访问方式C里处理这个网络和Python最大的区别在于Python拿到的是numpy数组索引随便写而C的net.forward()返回的是一个三维cv::Mat你不能用atVec3f(x, y)这种方式去访问必须把它当成一块连续内存来手算偏移。下面这段是C版的候选解析核心代码。#include opencv2/opencv.hpp #include vector int main() { cv::dnn::Net net cv::dnn::readNetFromONNX(face_keypoint.onnx); net.setPreferableBackend(cv::dnn::DNN_BACKEND_OPENCV); net.setPreferableTarget(cv::dnn::DNN_TARGET_CPU); const int input_size 640; const int num_keypoints 5; const int num_classes 1; const int channel_count 4 1 num_classes num_keypoints * 3; const float conf_thres 0.25f, iou_thres 0.45f; cv::Mat original cv::imread(test.jpg); cv::Mat img_640; // 假设已经把原图letterbox到了img_640ratio和pad已经算好存进变量 cv::Mat blob cv::dnn::blobFromImage(img_640, 1.0 / 255.0, cv::Size(input_size, input_size), cv::Scalar(0, 0, 0), true, false); net.setInput(blob); cv::Mat out net.forward(); int rows out.size[2]; // 8400 int channels out.size[1]; // 21 const float *data (const float *)out.data; std::vectorcv::Rect boxes; std::vectorfloat scores; std::vectorstd::vectorcv::Point2f kpts_list; for (int i 0; i rows; i) { float box_conf data[4 * rows i]; float cls_conf data[5 * rows i]; float score box_conf * cls_conf; if (score conf_thres) continue; float cx data[0 * rows i]; float cy data[1 * rows i]; float w data[2 * rows i]; float h data[3 * rows i]; float x1 (cx - w / 2 - pad_w) / ratio; float y1 (cy - h / 2 - pad_h) / ratio; float x2 (cx w / 2 - pad_w) / ratio; float y2 (cy h / 2 - pad_h) / ratio; boxes.push_back(cv::Rect(cv::Point(x1, y1), cv::Point(x2, y2))); scores.push_back(score); std::vectorcv::Point2f pts; for (int k 0; k num_keypoints; k) { float kx data[(6 k * 3) * rows i]; float ky data[(6 k * 3 1) * rows i]; kx (kx - pad_w) / ratio; ky (ky - pad_h) / ratio; pts.emplace_back(kx, ky); } kpts_list.push_back(pts); } return 0; }这里最关键的一句是data[channel * rows i]。OpenCV三维Mat在内存上是按通道连续排列的21个通道每个通道有8400个值所以定位第ch个通道的第i个候选偏移量要乘rows而不是channels。这是一个非常容易写错的地方——很多人第一次移植时写成了data[i * channels ch]结果拿到的数字全是乱的框和点飞得到处都是。如果你的模型输出排布恰好是 1×8400×21那访问方式就反过来data[i * channels ch]。建议把排布方式写成一个常量注释在代码顶部换模型时第一件事就是确认这个。4.3 关键点通用化为换车牌模型留好配置既然标题明确说了这套代码可以换成车牌4角点检测C版本里就不该把关键点数量写死在循环里。上面的代码里已经用了num_keypoints变量但还没有完全独立出来。更稳妥的做法是把模型相关参数放到一个结构体里换模型只改一个地方。struct ModelConfig { int input_size 640; int num_classes 1; int num_keypoints 5; // 换车牌角点模型时改成4 int kpt_channels 3; // x, y, visible float conf_thres 0.25f; float iou_thres 0.45f; int total_channels() const { return 4 1 num_classes num_keypoints * kpt_channels; } };用的时候这样取通道数int real_channels out.size[1];然后用real_channels去和config.total_channels()做校验。如果加载了一个18通道的车牌模型却忘了改num_keypoints校验会直接报错提醒你配置不对而不是等画图时发现点全错位。C的推理后端除了DNN_BACKEND_OPENCV还可以尝试DNN_BACKEND_INFERENCE_ENGINE或DNN_BACKEND_CUDA前提是你的OpenCV编译时带了对应插件。CPU单线程跑640输入的YOLOv8n大约在80到150毫秒如果你觉得慢优先级从高到低是先换小模型、再调输入尺寸到480、最后才考虑CUDA。5. 常见坑与排查从加载报错到坐标漂移的5条实战记录5.1 ONNX文件加载失败报错信息指向opset和算子现象readNetFromONNX抛出异常提示不支持某种节点或者干脆提示Cant parse ONNX model。换台机器偶尔出现同一份文件在别人电脑上却能跑。原因导出时opset版本过高或者模型里带了OpenCV不支持的算子。OpenCV的DNN模块更新速度慢于PyTorch和ONNX Runtime它认得的算子集合相对保守尤其是带循环结构的DFL解码逻辑最容易碰壁。解决把源码模型重新导出命令用opset12加上simplifyTrue。如果你拿到的ONNX是别人给的没法重新导出就先用ONNX简化工具跑一遍simplify去掉多余的Reshape和Transpose节点。再有条件的话写个三行代码把不支持算子逐一打印出来逐个用等价替代节点替换。5.2 NMSBoxes返回值格式不同代码直接崩溃现象Python版本在cv2.dnn.NMSBoxes之后做indices.flatten()时抛异常或者C版本里indices为空时访问越界。原因OpenCV这个接口的返回类型在4.x的各个小版本间变过。旧版本返回的是嵌套列表或者Nx1的Mat新版本返回的是普通列表固定写indices[0][0]这种访问方式就会翻车。解决写成兼容代码。Python端统一用np.array(indices).reshape(-1)先拍平成数组再遍历C端先判断Mat是否为空再决定用atint还是直接索引。这个代码只写一次后面所有项目都能复用。5.3 框和点都在但位置整体偏右下或者缩放不对现象人脸检测框比实际人脸大一圈关键点全部落在脸的下方偏右方向并且偏移量随着人脸离镜头越远越明显。原因letterbox还原公式写反了。常见两种错误一是把(x - pad) / ratio写成了(x / ratio) - pad二是pad_w、pad_h用的是原始图的像素值没有先换算到640尺度。坐标还原错位是这类部署最常见的血泪问题。解决先确认letterbox返回的pad是在哪个坐标系下的。如果我上面那个letterbox实现pad是在640输入图坐标系下算出来的还原公式就必须是(x_640 - pad) / ratio。拿一张人脸在画面正中间的图做测试如果还原后框往右下偏先试试把减pad改成在除ratio之后做。5.4 C推理结果和Python差很远框少了一多半现象同一个模型、同一张图Python版本能检到5张脸C版本只检到1到2张而且检测框看起来偏小。原因绝大多数情况下是两个版本的输入blob不一致。常见的是C里漏了scalefactor1/255.0把0到255的原始像素直接喂给了模型置信度分布整体异常另一常见问题是swapRB设置不同通道顺序错乱后特征响应会显著变差。解决把两端代码的blob创建写成一致的参数1/255.0、(0,0,0)、swapRBtrue、cropfalse。如果确认输入一致还差很多就在候选解析循环前面打印每个候选的前几个通道值和Python端对应位置的数值做对比这一步能快速定位是预处理问题还是索引问题。5.5 换成车牌4角点模型后绘图全错但代码跑通了现象模型换成车牌角点检测后没有任何报错但框能画出来角点却全跑到了图外面或者四个点分布在完全错误的位置。原因换模型时只改了num_keypoints没注意到两点一是新模型输出通道数变了所有通道索引都要重新对齐二是车牌角点模型的标签顺序可能和人脸关键点不同比如人脸是左眼、右眼、鼻子、左嘴角、右嘴角而车牌是按左上、右上、右下、左下排列直接复用绘制顺序就会画成对角交叉的线。解决改模型后第一件事是打印输出shape确认通道数是18而不是21第二件事是看训练时的标签定义把角点顺序对应到你的绘制逻辑里。我自己的习惯是先在代码里按顺序给点编号画图时把编号也标出来跑一次就一目了然。6. 换成车牌4角点最小改动与验证技巧从人脸5个关键点换到车牌4个角点在代码层面真的只是三件事。第一把num_keypoints从5改成4total_channels()会自动从21变成18第二检查输出通道数是不是18如果是且模型训练时坐标定义是输入图像素坐标后处理循环不需要动第三根据车牌角点的视觉顺序调整绘制逻辑别把右上角画到右下角去。给你一个验证的小技巧找一张车牌角度斜一点的测试图跑出4个角点之后不要把点直接连成四边形先按顺序画圈标数字看1、2、3、4在车牌的什么位置。如果发现1在右上、2在左上那就是label顺序和你的绘制顺序反了调整一下数组下标即可不需要动模型。还有个更快的判断方法是打印关键点通道的统计信息看看第6个通道的第0个关键点的x坐标均值如果一直集中在右侧区域说明这个点在标签定义里大概率是右眼或者右上角。我最后落一个建议任何模型换上来都先写三秒的脚本打印输出shape再写后处理这比在代码里猜索引省太多时间。我自己就因为在C工程里换车牌模型时忘了改num_keypoints画出来的四个点全在半空中排查了半天才发现是配置没同步。后来养成了习惯模型文件和配置文件放在一起每次换模型先核对通道数。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进