简介:面向算法部署与计算机视觉开发者,这份项目源码演示了如何利用OpenVINO加ONNX的流水线,部署支持68点与39点landmark的人脸关键点检测模型,尤其适合需要将PyTorch等训练模型迁移至英特尔硬件、追求实时推理的工程场景。资源共188个文件,压缩包大小32.59MB,核心内容涵盖Python源码、ONNX模型文件、pth/bin权重、特征点标签数据与测试图片,以及模型转换、OpenVINO优化和推理验证相关脚本与说明文档,目录结构清晰,便于按步骤对照学习。目前已有275人学习下载。通过项目源码,开发者可掌握从模型准备、格式转换、性能优化到部署验证的完整链路,项目特意提供MobileFaceNet权重与ONNX示例,并配有测试图片和演示动画,辅助快速构建可用的人脸关键点检测系统。同时,源码对模型输入输出格式、关键点索引顺序等细节做了明确约定,并附带常见问题说明,可帮助开发者在实际部署中快速定位与解决异常。
1. 算法部署的最后一公里:OpenVINO 吃下 ONNX 的完整链路
一个在 PyTorch 里单帧推理 3ms 的人脸关键点模型,转到 OpenVINO 之后反而跑出 10ms,这类问题在部署群里出现频率极高。多数时候问题不在推理引擎本身,而在模型导出那一步:算子被拆碎、动态 shape 卡住了 kernel 选择、量化校准集构造不规范,这些都会让 OpenVINO 的优化策略失效。这篇文章从 ONNX 导出出发,把 68 点 + 39 点 landmark 模型部署到 OpenVINO 的完整链路走一遍,覆盖模型转换、精度对比、可视化验证、CPU 线程参数调整和关键点平滑。适合手里已经有一个训练好的关键点模型、想在 x86 设备上压榨性能的工程师,也适合刚接触 ONNX 部署、想搞清「ONNX 模型是什么、为什么要转 OpenVINO」的开发者。
2. ONNX 导出的人脸关键点模型,先过算子边界这一关
2.1 为什么要 ONNX 夹一层:从训练框架到推理引擎的格式转换
ONNX 的价值在于它是训练框架和推理引擎之间的“中间语言”。PyTorch 导出的模型变成 ONNX 之后,计算图是完整的,算子列表是明确的,OpenVINO、ONNX Runtime、TensorRT 都能直接消费这份图。对人脸关键点部署来说,ONNX 这一层还有一层实际意义:换推理后端不用重新导出。
我一般先在 ONNX Runtime 上验证导出的模型输出,确认和 PyTorch 原模型一致,再交给 OpenVINO 做格式转换和精度优化。这样做的好处是排错时能明确问题归属——如果 ONNX Runtime 上输出已经不对,说明导出环节出了问题;如果 ONNX Runtime 正常但 OpenVINO 输出异常,才需要考虑算子映射或精度损失。
关键点模型导出时有一个容易被忽视的点:模型内部一般包含 resize、卷积、全连接或注意力模块。PyTorch 2.x 的torch.onnx.export对动态 shape 的兼容性已经不错,但动态 shape 会导致 OpenVINO 侧部分 kernel 无法走最优实现。对于固定输入尺寸的人脸关键点任务,建议导出时把输入 shape 固化成静态值。
2.2 torch.onnx.export 的硬性设置:opset、动态轴与导出代码
导出代码用torch.onnx.export即可,核心参数如下:
import torch model.eval() # 必须切到 eval 模式,dropout/batchnorm 行为才固定 dummy_input = torch.randn(1, 3, 224, 224) # 与训练时输入尺寸一致 torch.onnx.export( model, (dummy_input,), "landmark.onnx", opset_version=13, input_names=["input"], output_names=["landmark"], # 68点输出可以命名 landmark_68 dynamic_axes=None, do_constant_folding=True, )opset_version决定了 ONNX 图里算子集的粒度。OpenVINO 官方转换器对 opset 11 ~ 15 支持比较成熟,opset 13 是折中选择:既能覆盖关键点模型常见的卷积、归一化、激活算子,又不会因为 opset 太高引入 OpenVINO 暂未优化的新算子。
dynamic_axes=None是刻意为之。人脸关键点模型的输入通常是检测器裁好的 ROI,固定成1x3x224x224后,OpenVINO 能把图编译优化得更彻底。如果模型必须支持多尺寸输入,可以只把 H、W 设为动态轴:
dynamic_axes={"input": {2: "height", 3: "width"}, "landmark": {2: "height", 3: "width"}}但要注意动态轴会影响 OpenVINO 的算子融合效果,多 batch 场景下还要配合显式 shape 信息。
2.3 导出后必须做的两件事:shape 检查与数值对比
导出完成后第一件事是检查 ONNX 图的 shape 信息,用 onnx 自带的 checker 和 shape inference:
import onnx import onnxruntime as ort import numpy as np model_onnx = onnx.load("landmark.onnx") onnx.checker.check_model(model_onnx) onnx.shape_inference.infer_shapes(model_onnx, strict_mode=True) # 用 onnxruntime CPU 跑一遍对比输出 ort_session = ort.InferenceSession("landmark.onnx", providers=["CPUExecutionProvider"]) ort_out = ort_session.run(None, {"input": dummy_input.numpy()})[0] with torch.no_grad(): torch_out = model(dummy_input).numpy() # 对比最大绝对误差,阈值先看 1e-4 量级 diff = np.abs(ort_out - torch_out).max() print(f"max abs diff: {diff:.2e}")对比输出这一步能挖出不少潜在问题。很多关键点模型最后接的是全连接层直接回归 x/y 坐标,数值范围通常在 0~1 或 0~224 之间,绝对误差超过1e-2就要警惕导出过程改变了数值语义。如果模型输出是 heatmap(形状类似1x68x64x64),先确认有没有做 softmax 或 sigmoid,有些训练代码把激活函数漏在了损失函数里,导出时图里根本没有这一步,部署后推理结果就完全对不上。
这一章处理完,手里应该有一个在 ONNX Runtime 上验证过的人脸关键点模型文件。接下来把它交给 OpenVINO。
3. 从 ONNX 到 OpenVINO IR:转换、FP16 与 INT8 量化
3.1 OpenVINO 模型转换工具的演进:mo 还是 ovc
OpenVINO 2023.0 之前,模型转换主要靠mo命令行工具;2023.0 之后官方逐步收拢到ovc和 Python APIopenvino.convert_model。实际部署时没必要纠结新旧,只要确保 OpenVINO 版本和转换命令对应即可。
命令行转换最简单,一条命令把 ONNX 变成 OpenVINO 的 IR 格式:
ovc landmark.onnx --output_model landmark --compress_to_fp16--compress_to_fp16会把模型权重压缩为 FP16。这里有个容易误会的点:OpenVINO 在 CPU 上执行 FP16 模型时,底层 kernel 会自动把 FP16 权重转成适合 AVX-512 等指令集的形式来算,而不是真的用半精度做浮点运算。所以 CPU 侧 FP16 模型更多是减小 .bin 文件体积、降低 IO 带宽压力,预测损失通常小到可以忽略。
转换完会产生两个文件:landmark.xml描述计算图结构,landmark.bin存放权重。这也是 OpenVINO 部署最常见的落地形态。在 Python 侧也可以直接用 API 转换并立刻编译:
import openvino as ov core = ov.Core() model_ov = core.read_model("landmark.xml") # 直接读 IR compiled = core.compile_model(model_ov, "CPU")3.2 CPU 部署该选哪一档:FP16 还是 INT8
关键点模型本身很小,参数量通常只有几 MB,真正消耗时间的是卷积算子里的乘加运算和内存拷贝。INT8 量化能把权重和激活都压到 8bit,在支持 AVX-512 VNNI 的 CPU 上吞吐提升比较明显。
OpenVINO 的 INT8 量化是训练后量化(PTQ),不需要重训练或微调,但需要一个校准数据集。用 Neural Network Compression Framework(NNCF)的 Python API 来做:
import nncf import openvino as ov from torch.utils.data import DataLoader # 准备校准数据集:从训练集(或验证集)抽 200-300 张图 def transform_fn(data_item): images, _ = data_item return {"input": images.numpy()} calibration_loader = DataLoader(calib_dataset, batch_size=8, shuffle=True) model_ov = ov.convert_model("landmark.onnx") quantized_model = nncf.quantize( model_ov, calibration_dataset=nncf.Dataset(calibration_loader, transform_fn), preset=nncf.QuantizationPreset.MIXED, ) # 保存 INT8 IR ov.save_model(quantized_model, "landmark_int8.xml")preset=nncf.QuantizationPreset.MIXED是推荐做法:它对敏感层(比如最后的回归层)自动保持 FP32 计算,对卷积层用 INT8。人脸关键点模型的输出坐标对数值很敏感,如果全量化后点在原图上偏移超过 2 个像素,优先检查是不是回归层也被量化了。
3.3 校准数据集该怎么构造:landmark 场景的注意事项
一个常见错误是随便拿几十张测试图做校准。int8 量化找的是激活值的分布范围,校准集必须覆盖真实部署时的数据分布。对于人脸关键点场景,校准集中应该同时包含:
- 正脸、左右侧脸、抬头低头的人脸 ROI
- 不同肤色和光照条件的样本
- 遮挡(口罩、眼镜、头发)人脸样本
每类至少 20~30 张,总量 300 张左右就够。校准样本要经过和线上完全一致的预处理(mean/std、resize 方式),否则量化后的激活分布和实际部署时的分布会产生偏移,导致个别图片上关键点明显跳动。
转换和量化做完后,进入实际推理代码环节。
4. 关键点推理与坐标还原:OpenVINO 推理代码的落地
4.1 参考推理代码:compile_model 与一次 infer 跑完
OpenVINO 2023.1 之后推荐的 Python 推理方式是用compile_model而不是旧版的IECore、exec_net那套接口。下面这个类可以直接用于 68 点或复数 landmark 输出模型:
import cv2 import numpy as np import openvino as ov class OpenVINOLandmark: def __init__(self, xml_path, device="CPU", num_threads=4): self.core = ov.Core() # 关键参数:锁线程数,避免推理线程数超过物理核数导致上下文切换 self.core.set_property("CPU", {"NUM_STREAMS": "1", "INFERENCE_NUM_THREADS": num_threads}) self.model = self.core.read_model(xml_path) self.compiled = self.core.compile_model(self.model, device) self.input_name = self.compiled.input(0).any_name self.output_name = self.compiled.output(0).any_name def preprocess(self, bgr_roi, size=(224, 224)): """人脸关键点模型训练时一般用 RGB 或 BGR,要与训练保持一致""" img = cv2.resize(bgr_roi, size, interpolation=cv2.INTER_LINEAR) img = img.astype(np.float32) / 255.0 # 如果训练时有 mean/std 归一化,在这里补上 img = img.transpose(2, 0, 1) # HWC -> CHW return np.expand_dims(img, axis=0).astype(np.float32) def infer(self, bgr_roi): input_tensor = self.preprocess(bgr_roi) outputs = self.compiled([input_tensor]) return outputs[self.output_name][0] # shape: (num_points, 2) 或 (1, 136)INFERENCE_NUM_THREADS是 OpenVINO CPU 推理最值得调的参数。默认情况下 OpenVINO 会用满所有逻辑核,但如果同一台机器上还要跑检测模型、视频解码、图像缩放,全核心抢占反而让总时长变长。我一般先设成 4,再根据端到端延迟调整。NUM_STREAMS=1则在单路视频场景下避免 OpenVINO 自动开多个流水线插队。
4.2 坐标还原:归一化坐标到原图坐标的映射
模型输出坐标一般有两种形式:一种是直接输出归一化后的 0~1 坐标,另一种是输出以输入图为单位的绝对像素坐标。以 68 点 landmark 来说,输出通常是1x136的向量,每两个数一组,对应一个点的 x 和 y。
ROI 来自于检测框,推理得到的坐标需要换算回原图:
# outputs 归一化坐标 (0~1) points = outputs.reshape(-1, 2) # roi_rect: (x, y, w, h) 检测器裁出来的人脸区域 x, y, w, h = roi_rect abs_points = points * [w, h] + [x, y] # 如果推理前做了 letterbox,需要反推缩放补偿 scale = min(roi_w / input_w, roi_h / input_h) offset_x = (roi_w - input_w * scale) / 2 offset_y = (roi_h - input_h * scale) / 2 abs_x = (points[:, 0] * input_w * scale) + x + offset_x abs_y = (points[:, 1] * input_h * scale) + y + offset_y坐标落在边缘外 1~2 个像素通常没问题,但如果所有点都系统性偏右下 5 个像素以上,基本可以确定是预处理 RGB/BGR 顺序反了,或者归一化参数写错。关键点部署中这类系统性偏移是最常见的“看起来能用,实际很糟”的状态。
4.3 三个必调参数:NUM_STREAMS、线程数与模型缓存
除了线程数和流数,还有一个参数容易被忽略:CACHE_DIR。OpenVINO 首次编译模型时会做大量图优化和 kernel 选择,耗时可能到几秒甚至十几秒。首次编译的结果可以缓存到本地:
self.core.set_property("CPU", {"CACHE_DIR": "./ov_cache"})加了CACHE_DIR之后,第二次启动加载模型可以从毫秒级开始推理。在嵌入式设备或 Jupyter 场景下这个参数能省下大量反复调试的时间。三个参数的优先级建议是:先加缓存解决启动耗时,再调线程数控制 CPU 占用,最后改NUM_STREAMS适配单路或多路视频。
5. 性能分层、多路视频与关键点平滑:部署后的三个实用技巧
5.1 用延迟分布而不是单次耗时评估部署效果
把 OpenVINO 跑通了之后,先别急着上逻辑。跑一把延迟分布统计,看 p50 和 p95 的差距:
import time import numpy as np latencies = [] for i in range(200): # 先 warmup 20 次,再统计 if i >= 20: t0 = time.perf_counter() compiled([dummy_input]) if i >= 20: latencies.append((time.perf_counter() - t0) * 1000) p50 = np.percentile(latencies, 50) p95 = np.percentile(latencies, 95) print(f"p50: {p50:.2f} ms, p95: {p95:.2f} ms")p50 低但 p95 高,说明存在偶发调度或内存分配抖动;如果 p95 超过 p50 的 3 倍,优先检查是不是每次推理时输入数据都重新分配了内存,建议推理前固定分配好输入 buffer。
5.2 异步推理与双缓冲
单路视频用同步推理就够,但处理多路视频流时,同步推理会让采集和推理互相等待。OpenVINO Python API 的异步模式在AsyncInferQueue中封装得比较完整,核心思路是:采集线程不断投递帧,推理线程不断消费,采集和推理并行执行。对于关键点模型这种小模型,异步带来的延迟提升有限,但吞吐提升明显,多路视频场景下建议一步到位用异步。
5.3 关键点平滑与质量验证:旋转场景下的免拖尾技巧
视频流里单帧坐标抖动很常见,直接对坐标做一阶指数平滑(EMA)是最简单的做法:
# alpha 取 0.3~0.5 之间,越小平滑越强但延迟越大 smoothed = alpha * current_pts + (1 - alpha) * smoothed_pts但人脸快速转头时,坐标变化很大,单纯平滑会让点拖尾。改进办法是按角度平滑:先计算当前帧和上一帧点集之间的旋转角度,对角度做平滑,再对坐标做轻微平滑。这样头部转动时点会跟着走,停留时才逐渐稳定,视觉上比直接平滑坐标自然很多。
最后用两个指标做部署后的质量验证:一个是回归误差,对测试集算预测点与标注点之间的平均欧氏距离(归一化到框宽高);另一个是时间抖动,即连续帧之间 landmark 坐标变化的最大值。如果时间抖动超过 5 个像素但回归误差正常,说明平滑参数偏激进,调小 alpha 即可。部署验收建议以「单帧延迟 + p95 + 坐标漂移」三个数字为准,这三个数值能覆盖大部分后期维护场景。
本文还有配套的精品资源,点击获取