简介:面向深度学习开发者及计算机视觉入门者,这份人脸检测推理资源包以YOLOv8模型为核心,解决了从模型加载到输出标注结果的部署问题。压缩包共包含十五个文件,总大小约7.34MB,内含五个Python源码文件、四张JPG测试图像、模型权重目录、环境依赖清单、说明文档以及自动下载脚本,既覆盖核心推理逻辑,也提供可直接运行的环境配置,适合快速验证模型效果。目前已有137人学习下载。借助压缩包内的内容,使用者可以完整了解ONNX格式下YOLOv8人脸检测的输入输出流程,掌握调用主程序进行图像检测并得到可视化结果的方法,同时利用自动化脚本减少依赖准备的时间成本。压缩包内分模块存放权重、模型、工具函数等资源,结构清晰,便于后期替换模型或扩展功能,特别适合希望本地落地YOLOv8人脸检测、或以此为起点开展二次开发的学习者。
1. 拿到这个压缩包后,先搞清楚它在解决什么
如果你手里拿到的是一个名为“YOLOv8人脸检测ONNX推理.zip”的项目包,大概率是两种处境:要么你在做边缘设备或摄像头端的人脸检测接入,要么是接手上一个同事留下的部署交接。这类包解决的是一条很具体的链路——把训练手里那张best.pt(YOLOv8 在人脸数据上训练出的权重)转成.onnx,再交给 onnxruntime 这类推理引擎去跑,最终输出人脸置信度和边界框。它能帮你绕开没有 PyTorch 的训练环境、绕开 CUDA 版本地狱,甚至在没有 GPU 的嵌入式盒子上也能做到几十毫秒出框。
但这个压缩包绝不是“解压即用”的魔法。常见的形态是:一个.onnx模型、一个requirements.txt、一个预处理脚本加一个后处理脚本。真正决定你要不要加班的细节,全藏在预处理对齐、坐标逆映射、NMS 阈值选择和推理引擎配置里。下面按我完整复现这一类包时的顺序,把代码、参数和坑一次讲完。
2. 模型侧准备:从 PyTorch 权重到 ONNX 导出的落地路径
2.1 为什么人脸检测会选 YOLOv8,而不是 RetinaFace、MTCNN
先聊选型。人脸检测学术圈里 RetinaFace 和 MTCNN 名气很大,但你在实际部署包里见到 YOLOv8 的频率反而更高,原因不是精度谁更高,而是工程出口更顺。RetinaFace 多一条关键点回归分支,导出 ONNX 后会带上不少自定义网格采样算子,后续转 RKNN、TensorRT 时经常掉算子;MTCNN 是三个小网络串行检测,逻辑简单,但速度和密集小脸场景表现都吃亏。YOLOv8 是 anchor-free 单阶段检测器,输出就是一个纯张量,任何推理引擎都原生支持解码和 NMS,这条链路短且不容易出幺蛾子。
另外,人脸检测在 YOLOv8 里通常是单类或二类任务,比如“face”一类,或者“face + mask”两类。这直接影响输出张量的形状:单类模型的output0形状是[1, 5, 8400],COCO 80 类的是[1, 84, 8400]。后处理解码时千万别用固定 84 去切,否则 classes 的索引和置信度全对不上。你要是自己用 YOLOv8 训练过自己的数据集,这一步感受会更明显,因为官方权重默认是 80 类,而你的人脸权重往往只有 1 类。
不是所有场景都适合 YOLOv8。如果你后续要使人脸对齐、活体检测或者质量评分,一个框和一置信度是不够的,还得外接 landmark 或 embedding 模型。但如果你只是做“画面里有没有人脸、有多少张、框在哪”,YOLOv8 的推理链路最短,维护成本最低,是合理的默认选择。
2.2 PyTorch 转 ONNX:优先用官方 export,别手写 torch.onnx.export
很多同学拿到.onnx包后想复现导出过程,第一步就去网上抄一段torch.onnx.export。抄过来往往能导出,但推理时框全偏,因为手写导出要考虑的细节很多:导出前要model.eval()、要把输入名字绑定成images、要决定要不要保留动态轴。更稳的做法是用 YOLOv8 自带的model.export(),因为它在内部已经处理了这些枝节。常见写法如下:
from ultralytics import YOLO # 加载你自己训练出来的人脸检测权重 model = YOLO("weights/best.pt") model.export( format="onnx", opset=12, # 对 onnxruntime 和 RKNN/OpenVINO 等后端更友好 dynamic=True, # 允许 batch 维度动态,为视频流 batch 推理留空间 simplify=True, # 用 onnx-simplifier 去掉冗余算子 imgsz=640, ) # 导出完成后会在 weights/ 下生成 best.onnx这里每个参数都有讲究。opset=12是我的一贯选择,别贪新版本。opset 13 以上的某些算子,比如Resize的坐标变换模式或ScatterND的数值行为,在不同推理引擎上的实现存在细微差异,而这在 RK3588 的部署环节会被放大。先选 12 可以把兼容性风险压到最低。
dynamic=True允许 batch 维度动态。单人脸推理时是 1,想凑 batch 时可以是 2 或 4,这不会改变模型结构,但会让 onnxruntime 每次遇到新维度时多一次 shape 分析和内存重分配。如果你只做单路视频流,固定imgsz即可,动态轴留给以后扩展。
simplify=True等价于做了一次计算图瘦身。YOLOv8 导出时经常带着对部署无用的Split、Concat、Slice节点,简化后加载更快。但要注意,简化不是无损操作,输出 tensor 的排列顺序在某些版本里会变化,这个问题在第五章专门讲。
如果你确实需要手写导出,可参考下面模板:
import torch from ultralytics import YOLO model = YOLO("weights/best.pt") model.model.eval() dummy = torch.randn(1, 3, 640, 640) torch.onnx.export( model.model, dummy, "face_det.onnx", opset_version=12, input_names=["images"], output_names=["output0"], dynamic_axes={ "images": {0: "batch"}, "output0": {0: "batch"}, }, )逻辑说明:model.model取的是 YOLO 底层的nn.Module,这样torch.onnx.export能直接拿到可导出的计算图。dummy是固定尺寸的占位输入,训练时的输入尺寸是 640,导出时最好保持一致。dynamic_axes只开放了 batch 维度,输入分辨率仍是静态的;如果想在推理时换 320 或 1280,就得重新导出。所以我的建议是:能用model.export()就别手写,手写只适用于你要改输出节点名、裁剪输出头或做多模型拼接的定制场景。
导出前还要确认 ultralytics 环境版本。不同版本的导出行为有差异,比如旧版对opset的支持和新版不同,要是不确定,先跑一次导出,再用onnxruntime加载并打印输出形状。这一步能暴露绝大多数“半成品包”问题:模型能加载,但输出 shape 和你预期不一致。
2.3 用 onnxruntime 跑通第一版推理:最小可运行脚本
本地推理引擎里,onnxruntime 是我每次的默认起点。它在 PC 上只需要pip install onnxruntime,加载模型和输出结果都直接。第一次跑通“命令式”脚本,比上来就封装成类更不容易出错:
import cv2 import numpy as np import onnxruntime as ort # 优先用 CUDA,没有 GPU 就自动落到 CPU sess = ort.InferenceSession( "weights/best.onnx", providers=["CUDAExecutionProvider", "CPUExecutionProvider"], ) img = cv2.imread("test.jpg") # 一个能跑的临时预处理,注意不是最终方案,见第三章 img_640 = cv2.resize(img, (640, 640)) rgb = cv2.cvtColor(img_640, cv2.COLOR_BGR2RGB) input_tensor = rgb.transpose(2, 0, 1)[None].astype(np.float32) / 255.0 outputs = sess.run(None, {"images": input_tensor}) print("output shape:", outputs[0].shape)providers列表是有顺序的:onnxruntime 会从上往下找可用执行提供程序,第一个不可用就落到第二个。这个配置保证同一份代码在 N 卡机器和纯 CPU 机器上都能跑。sess.run(None, ...)表示取全部输出,YOLOv8 只有一个output0。输入名images必须和导出时的input_names一致,否则直接报找不到输入。
这段脚本的价值是把“黑匣子”打开:很多人第一步没跑通就怀疑模型坏了,实际上要么是路径写错,要么是输入名不匹配。正常打印出来应该是[1, 5, 8400]或[1, 84, 8400],到这里,模型侧的准备算是齐了。
3. 推理工程化:引擎、预处理、后处理三个环节缺一不可
3.1 推理引擎选型:onnxruntime 是默认项,OpenVINO、RKNN 是下一级台阶
在没确定部署硬件前,onnxruntime 是所有推理引擎里最不需要犹豫的默认项。它覆盖 x86、ARM,CPU 版安装不需要额外依赖,GPU 版也只是多了 CUDA 和 cuDNN 两个运行库。但如果你的最终硬件是瑞芯微 RK3588 这类带 NPU 的边缘平台,onnxruntime 只能作为精度对拍工具,真正上线跑的是 RKNN Toolkit 转换出的.kmodel。不要把“ONNX 推理”和“边缘部署”混成一个阶段。
| 引擎 | 适用场景 | 主要注意点 |
|---|---|---|
| onnxruntime CPU | PC 端快速验证、低并发服务 | 1.10 以下版本对动态 shape 支持不好 |
| onnxruntime GPU | 有 N 卡的 PC 服务 | CUDA/cuDNN 版本必须对齐,否则翻车 |
| OpenVINO | Intel CPU/核显边缘设备 | 自带 NMS 扩展,但部分算子需要先验证 |
| RKNN | RK3588 等瑞芯微 NPU | ONNX 需转.kmodel,量化后重测精度 |
为什么单独说引擎选型?因为同一个 ONNX 模型在不同引擎上的数值表现不完全一样。不同引擎对Sigmoid、Resize、Exp等算子的实现精度或融合策略不同,输出可能相差零点几个百分点。在人脸这类小目标上,哪怕几个像素的偏差,最终画出来的框也会有肉眼可见的差别。所以正确路径是:先在 onnxruntime CPU 上把算法链路跑通,再根据目标硬件换引擎或转格式,每次换完都要用同一套验证图重新评估。
3.2 预处理必须对齐训练:letterbox、RGB 顺序与填充值
ONNX 推理最容易出错的是预处理。YOLOv8 训练时用 letterbox 把图像等比缩放到 640x640,多余区域以 114 填充。如果推理时直接用resize拉伸,物体变形,框不可能准。这类压缩包最常见的“半成品”问题就在这里。预处理必须复现训练流水线:
def letterbox(img, new_shape=(640, 640), fill=114, stride=32): h, w = img.shape[:2] r = min(new_shape[0] / h, new_shape[1] / w) new_w, new_h = int(round(w * r)), int(round(h * r)) resized = cv2.resize(img, (new_w, new_h), interpolation=cv2.INTER_LINEAR) canvas = np.full((new_shape[1], new_shape[0], 3), fill, dtype=np.uint8) dx = (new_shape[0] - new_w) // 2 dy = (new_shape[1] - new_h) // 2 canvas[dy:dy + new_h, dx:dx + new_w, :] = resized return canvas, r, dx, dy这个函数返回三个值:r是缩放比,dx、dy是左右和上下填充的像素数。推理完的框在 letterbox 坐标系里,要映射回原图,需要减掉dx、dy,再除以r。填充值 114 不是玄学,是 YOLO 训练代码里的固定像素值,换成 0 或 127 都会让边缘区域的特征分布偏移,小脸检测会掉点。
还有一步常被漏掉:OpenCV 读图得到的是 BGR 顺序,而 PyTorch 训练时用的是 RGB。如果只做transpose不调换通道顺序,模型看到的是“红蓝颠倒”的图,人脸这种对肤色和纹理敏感的目标,置信度会集体下降。你还会误以为是模型量化坏了,实际只是通道顺序没换:
img_letter, r, dx, dy = letterbox(img, new_shape=(640, 640)) rgb = cv2.cvtColor(img_letter, cv2.COLOR_BGR2RGB) blob = rgb.transpose(2, 0, 1)[None].astype(np.float32) / 255.0 outputs = sess.run(None, {"images": blob})到这步,blob的形状是[1, 3, 640, 640],取值在 0 到 1 之间。YOLOv8 导出成 ONNX 后不会替你归一化,这步必须在外层完成。我曾经见过有人把预处理封装在模型里又在外层重复做一次,结果像素值小到 0.003,输出全是背景,最后靠打印输入均值才发现。
3.3 后处理:从输出张量到人脸框的完整解码
YOLOv8 输出张量的语义是:8400 个候选框,每个候选框前 4 个值是中心点坐标和宽高(cx, cy, w, h),后面跟着每个类别的置信度。需要特别注意的是,YOLOv8 已经去掉了 objectness 分支,输出里的得分就是类别得分本身,不需要再像 YOLOv5 那样乘“框置信度×类别置信度”。
def process_outputs(outputs, conf_thres=0.25, iou_thres=0.45, num_classes=1): preds = outputs[0] # [1, 4+num_classes, 8400] preds = preds.squeeze(0).transpose(1, 0) # [8400, 4+num_classes] boxes = preds[:, :4] # cx, cy, w, h scores = preds[:, 4:] if num_classes == 1: conf = scores[:, 0] else: cls_id = scores.argmax(1) conf = scores.max(1) mask = conf >= conf_thres boxes, conf = boxes[mask], conf[mask] if boxes.shape[0] == 0: return np.empty((0, 4)), np.empty(0) # 中心点+宽高 -> 左上右下 xyxy = np.zeros_like(boxes) xyxy[:, 0] = boxes[:, 0] - boxes[:, 2] / 2 xyxy[:, 1] = boxes[:, 1] - boxes[:, 3] / 2 xyxy[:, 2] = boxes[:, 0] + boxes[:, 2] / 2 xyxy[:, 3] = boxes[:, 1] + boxes[:, 3] / 2 keep = nms(xyxy, conf, iou_thres) return xyxy[keep], conf[keep]这里squeeze(0)去掉 batch 维度,transpose(1, 0)把候选框数量放到第一维,后面取值就方便了。num_classes必须由真实模型决定:单类人脸模型传 1,二类传 2,拿 COCO 权重导出就要传 80。这个参数错了,后处理一切白搭。
配套的 NMS 我不太建议依赖cv2.dnn.NMSBoxes,它的输入格式和 YOLO 输出习惯不一致,手写一个简洁版更可控:
def nms(boxes, scores, iou_thres=0.45): x1, y1, x2, y2 = boxes[:, 0], boxes[:, 1], boxes[:, 2], boxes[:, 3] areas = (x2 - x1 + 1) * (y2 - y1 + 1) order = scores.argsort()[::-1] keep = [] while order.size > 0: i = order[0] keep.append(i) xx1 = np.maximum(x1[i], x1[order[1:]]) yy1 = np.maximum(y1[i], y1[order[1:]]) xx2 = np.minimum(x2[i], x2[order[1:]]) yy2 = np.minimum(y2[i], y2[order[1:]]) inter = np.maximum(0.0, xx2 - xx1 + 1) * np.maximum(0.0, yy2 - yy1 + 1) iou = inter / (areas[i] + areas[order[1:]] - inter) order = order[np.where(iou <= iou_thres)[0] + 1] return np.array(keep, dtype=int)这段实现里容易写错的地方有两个。一是order[1:],它排除当前框取其余所有候选框;二是最后np.where(...)[0] + 1,加 1 是为了把索引位置对齐回order里的原始偏移,否则会把当前框自己也算进去抑制一次,导致永远只剩一个框。写完后用五个框叠一起的用例自测一下,比跑通一个场景更重要。
后处理拿到的框还在 letterbox 空间里,需要逆变换回原图:
boxes[:, [0, 2]] = (boxes[:, [0, 2]] - dx) / r boxes[:, [1, 3]] = (boxes[:, [1, 3]] - dy) / r到这里,图像到人脸框的闭环才算成立。把这个闭环封装成一个类,把 letterbox、推理、解码、NMS 四个函数独立开,后面换引擎、调阈值、压尺寸,改动的是局部而不是全部。
4. 参数怎么调不翻车:人脸场景的阈值、输入尺寸与评测
4.1 置信度阈值和 NMS IoU 阈值:密集人脸场景下的拉扯
人脸检测跟通用目标检测的参数策略不一样。监控和会议画面里人脸密集且互相遮挡,conf_thres设太高,后排小脸全被滤掉;设太低,背景里的假阳性能把人烦死。我一般从conf_thres=0.25、iou_thres=0.45起步,这是 YOLOv8 默认值,也是验证指标对齐的基准。
当人脸密集时,iou_thres更敏感。人脸框天然重叠,NMS 阈值偏大,叠得很近的两张脸都能保留;偏小,其中一张会被错误抑制。经验上,会议画面用 0.5 左右;超过 0.6 会出现同一个人脸框成两个框的现象。做闸机、考勤这类“一个人都不能漏”的场景,我倾向于把conf_thres压到 0.15,再用后续的二次筛选或跟踪算法补漏检,而不是只靠调阈值硬扛。
另一个容易忽略的是置信度分布。人脸检测器在光照差、侧脸角度大时,置信度往往集中在 0.1 到 0.3 之间。如果只看最好的一两张图,你会觉得 0.25 没问题;放到整段视频里,你会发现框闪个不停。所以调参之前先把自己的验证集跑一遍,画出置信度直方图,看切割点落在哪里,比拍脑袋给阈值靠谱得多。
4.2 输入尺寸和动态 shape:用显存换时延,还是用精度换速度
被问得最多的问题是“到底用 320 还是 640”。这个问题的答案取决于目标硬件和实时性。输入尺寸降一半,计算量是平方级别下降。拿 GTX 1660 Ti 这种老卡粗略实测过:640 输入单帧推理约 15-25ms,降到 320 能压到 6-10ms,但小脸召回率肉眼可见下降。这不是玄学,而是下采样把人脸细节抹掉了。
判断逻辑应该是:先看部署设备能容忍多高的延迟。如果摄像头流是 25FPS,单帧预算 40ms,640 是合理起步;如果要跑在 RK3588 这类 NPU 上,同时还要考虑整机功耗和散热,那就得把 int8 量化和 320 输入一起评估,而不是只改输入尺寸。
动态 shape 这里有一个隐藏成本。dynamic=True导出后,onnxruntime 每次遇到新输入尺寸都要重新做一轮 shape 推理和内存规划,速度不如静态图稳定。视频流推理里每帧都换一个分辨率,帧率会忽高忽低,这是很多人都没注意到的“假动态”陷阱。我的做法是固定输入分辨率,动态轴留给 batch 维度,这样既保住了速度,也为以后多路视频并行留了余地。
4.3 不只看单张图效果:用最小验证集统计 mAP 和 FPS
最容易自我感觉良好的验证是反复看同一张测试图。正确做法是,准备几十张带标注的人脸图跑一遍,统计 mAP 和全链路 FPS。评估骨架参考如下:
def run_eval(session, image_dir, label_dir, conf_thres=0.25, iou_thres=0.45): # 1. 解析 label 文件里的 gt 框 # 2. 对每一帧图片: # letterbox + 归一化 # session.run # process_outputs # 坐标映射回原图 # 3. 用 IoU>=0.5 与 gt 匹配,统计 TP/FP/FN # 4. 输出 mAP@0.5 和平均端到端时延 pass这里有个关键细节:真实 FPS 要把“读取图片、预处理、推理、后处理、画框”全链路算进去。视频流场景里,H.264 解码往往是隐藏瓶颈,可能占整个链路的 1/3 时间。如果你只卡模型推理耗时,留给硬件预算的余量会被解码吃掉,最后上线才发现帧率不达标。
除了 mAP,我还会额外统计“被 NMS 抑制掉的召回损失”。有时候模型输出里本来有正确框,但后处理参数把它们当重复框吃掉了,只看 mAP 曲线发现不了。把 NMS 前的置信度和 NMS 后的保留框数量同时打印出来,才能判断参数是否合理。用这套方法,换引擎、换量化、调输入尺寸之后的收益和损失都是可数字化的,而不是“感觉差不多”。
5. 人脸检测 ONNX 推理常见坑:五个现象、原因与解法
5.1 现象:模型能推理,但输出数组全是 0
有人复现这类包时,输出全是 0 或者极小负值,一个框都检测不出来。原因通常是预处理三连里至少错了一步:没做 BGR 到 RGB、没除以 255、或者transpose顺序不对。还有的代码里封装了三层,预处理在某个环节被执行两次,像素值被除到接近 0。
解决方法是把预处理拆出来打印。在sess.run前打印input_tensor.min()和input_tensor.max(),正常应接近 0 和 1;再打印第一个通道的像素均值,确认是 R 通道还是 B 通道。单步调试比在模型上反复调阈值高效得多。如果输出不是全 0,但置信度普遍低于 0.05,重点检查输入分辨率是否和导出时一致,不一致会导致下采样后的 anchor 数量对不上。
5.2 现象:框能出来,但整体偏移、位置不对
这是 letterbox 后处理没对齐的典型症状。最常见的是推理时直接resize(640, 640)塞给模型,然后用模型输出坐标除以缩放比,完全没考虑 padding。因为 YOLOv8 输出坐标建立在完整 640x640 输入图上,左右两条填充带会把脸框往中间推,上下的填充会把框往上下挤。
解决核心是“预处理和后处理必须是一对”。训练时的 letterbox 用了stride相关的取整逻辑,推理侧写简化版时,也要把r、dx、dy完整带回坐标逆变换。我一般在代码里把这两个函数放在同一个文件,再加一个回归测试:对一张已知坐标的图做一次 letterbox 再逆变换,坐标应回到原位置,误差小于 1 像素。这个测试能挡住 90% 的坐标偏移问题。
5.3 现象:PyTorch 里精度正常,ONNX 里 mAP 掉 0.2
如果你在 PyTorch 用测试集评测有 0.85 的 mAP,转成 ONNX 只剩 0.6,原因通常不是模型坏了,而是导出和运行环境不一致。高发点有三个:一是half=True导出,半精度在 CPU 上跑会触发大量精度不足的算子;二是opset版本过高,某些算子在后端被替换成数值行为不一致的实现;三是动态输入下,某个卷积的 shape 推导被固定成静态,相当于隐式改了模型结构。
解决路径是先用fp32 + opset12导出一版,在 onnxruntime CPU 上对拍同一张图,和 PyTorch 输出做逐元素误差统计,差异应在 1e-3 以内。如果差异太大,就逐个输出节点比较,定位到具体算子。别把“ONNX 掉精度”当玄学,大部分是导出配置问题。
5.4 现象:开多线程并发推理,吞吐不升反降
按“多线程调用同一个 InferenceSession”的套路写,在 onnxruntime CPU 版上大概率会碰壁。原因有两个:一是 session 内部的线程池默认占用多个核心,多线程再并发调用时互相抢核;二是 onnxruntime 某些算子内部有锁。结果就是看起来 8 个线程在跑,实际大部分时间在排队。
解决方法是:一个进程里只保留一个推理线程,用队列做前后端解耦;如果确实要多路视频并行,就给每个摄像头独立建一个 session,并显式设置线程数。在 RK3588 这类多核 ARM 平台上,常见效果是“单线程连续推理”比“多线程频繁切换”更快,因为 NPU 本身是排队处理任务的。
5.5 现象:用 onnx-simplifier 之后输出 shape 变了
simplify=True让模型更干净,但并不是无损操作。某些版本会把 YOLOv8 的Concat提前、把Slice合并,最终输出 8400 个 anchor 的排列顺序可能和原始模型不一致。后处理还按旧顺序去对应不同尺度的框,结果模型能跑,框的位置全是乱的。
解决办法是把简化前后的模型分别导出,对同一张图比较output0的数值差异。如果数值差异极小但推理端表现不一致,就把坐标映射和 NMS 分开验证,优先用未简化模型跑通链路,再决定简化版是否值得沿用。
6. 从单图到视频流与边缘部署:FPS、量化与可复现的验证习惯
6.1 视频流式推理管线:队列、丢帧与吞吐
单图跑通后进入视频流。最简单是“读一帧、推理一帧、画框一帧”的死循环,但这样解码、推理、显示强耦合,一帧卡住整条链路卡死。常见做法是生产者-消费者模型:解码线程往队列放帧,推理线程从队列取帧。
import threading import queue frame_q = queue.Queue(maxsize=2) result_q = queue.Queue(maxsize=2) def infer_loop(session): while True: frame, r, dx, dy = frame_q.get() blob = preprocess(frame) outputs = session.run(None, {"images": blob}) boxes, conf = post_process(outputs, r, dx, dy) result_q.put((frame, boxes, conf)) threading.Thread(target=infer_loop, args=(sess,), daemon=True).start() # 主循环只负责读帧和画框,推理线程持续工作maxsize=2是经验值:队列太小,解码线程频繁阻塞;队列太大,推理跟不上时延迟越来越大,画面越来越旧。视频流里“丢旧帧”比“每帧都排队”更合理,人脸检测要的是实时性,不是离线短视频回放。
6.2 从 ONNX 到 RK3588:int8 量化不是白捡的性能
如果最终目标是 RK3588,ONNX 只是中转。常见做法是用 RKNN Toolkit 读取.onnx,配置输入尺寸、均值方差,再做 int8 量化转成.kmodel。量化后模型体积更小,NPU 推理更快,但代价是精度损失,人脸这种小目标对量化很敏感。量完之后必须用同一套验证集重新跑一次 mAP,而不是直接上线。
注意:量化后掉点超过 0.05 mAP 时,返回导出环节检查模型里是否有
Sigmoid、Exp这类对数值范围敏感的层,在 RKNN 的量化配置里把它们单独设为不量化。RKNN 工具链提供的逐层精度分析不要跳过。
6.3 一个值得长期坚持的验证习惯
最后说一个我的工作习惯:每次换引擎、换尺寸、换量化方式,都会把同一组验证图完整跑一遍,把 mAP、FPS、量化前后差异记成 JSON 文件。这些数据不删,下个月再调参时直接翻记录,不用靠脑子回忆。这个做法在 PC CPU、N 卡、RK3588 上都能复用,能帮你快速判断某一步优化到底值不值得。
我在这个方向上踩得最亏的一次,就是省掉了量化后对拍,上线才发现某个算子的实现差异让所有框偏了 3 个像素。人脸框切出来送去识别时,识别率掉了一截,排查花了一个多星期。从那以后,坐标映射验证和量化前后对比这两步我都不敢再跳过。希望这组方法和踩坑记录能帮到你。
本文还有配套的精品资源,点击获取