YOLOv8n INT8量化实战

用 128 张 COCO 图片,实现「数据准备 → ONNX 导出 → 静态 INT8 量化 → mAP / 延迟 / 一致性评估 → 报告」的完整流程。

先看结果

指标 FP32 ONNX INT8 QDQ 变化
模型体积 12.82 MB 3.68 MB 3.49 \\times 更小
mAP@50 0.6184 0.5940 -0.0244(-3.9%)
mAP@50-95 0.4713 0.4161 -0.0552(-11.7%)
精确率 P 0.6398 0.6415 +0.0017
召回率 R 0.5486 0.5207 -0.0279
CPU 推理 p50 20.60 ms 10.99 ms 1.87 \\times 更快
CPU 吞吐(mean) 47.72 FPS 87.61 FPS +84%
检测框保留率 --- 93.1% 274 中保留 255
匹配框平均 IoU --- 0.871 位置高度一致

验证集:COCO128 的 64 张图、429 个 GT 实例、65 个类别。INT8 模型 3.68 MB,CPU 单图 11 ms,mAP@50 只掉 3.9%,mAP@50-95 掉 11.7%。

量化方案与环境准备

方案选择

PTQ 还是 QAT?量化路线分为:

  • PTQ(Post-Training Quantization,训练后量化):拿训练好的 FP32 模型,用一小批校准数据统计激活范围,直接转 INT8。不用重新训练,几秒钟到几分钟搞定,适合快速上线。
  • QAT(Quantization-Aware Training,量化感知训练):训练时模拟量化误差,让模型学会适应 INT8。精度通常更好,但要改训练流程、重新训练,成本高。

此次目标是用最小成本验证「YOLOv8n 能不能压到 INT8」,所以选 PTQ。

INT8 量化又分动态和静态:

  • 动态量化:运行时根据每个 batch 的激活值动态算 scale,适合 LSTM/Transformer 这类激活分布变化大的模型;卷积网络用它收益有限,很多后端也不支持。
  • 静态量化:先用校准集跑一遍,统计每层激活的 \\min/\\max ,推理时用固定 scale。卷积层是主要计算量,静态 INT8 在 CPU/边缘芯片上收益最大。

格式上选 QDQ(QuantizeLinear / DequantizeLinear):

\\text{FP32 weight} \\xrightarrow{\\text{QuantizeLinear}} \\text{INT8 Conv} \\xrightarrow{\\text{DequantizeLinear}} \\text{FP32}

Q/DQ 节点显式写在 ONNX 图里,ONNX Runtime、TensorRT、OpenVINO 都能识别;出问题时也能直接看哪一层被量化了。

为什么先转 ONNX,而不是直接量化 .pt?

PyTorch 自带的 torch.ao.quantization 对检测模型并不友好:YOLOv8 的检测头里有 Concat、Resize、Sigmoid、Split 等结构,静态量化需要手工处理很多边界;而且量化后的 .pt 最终往往还是要导出 ONNX 才能部署。

ONNX Runtime 的量化工具链成熟、API 简单,产出的 ONNX 可以直接给 ORT / TensorRT / OpenVINO 用。所以流程定为:

\\text{yolov8n.pt} \\xrightarrow{\\text{export}} \\text{yolov8n.onnx (FP32)} \\xrightarrow{\\text{quantize_static}} \\text{yolov8n_int8.onnx}

环境准备

建虚拟环境
bash 复制代码
sudo apt update
sudo apt install -y python3.11-venv

python3 -m venv ~/.venvs/py-ai
source ~/.venvs/py-ai/bin/activate
python -m pip install -U pip
安装依赖
bash 复制代码
pip install ultralytics==8.4.183 \
            onnx==1.23.2 \
            onnxruntime==1.31.0 \
            opencv-python \
            matplotlib \
            pyyaml

说明:

  • ultralytics 会自动装 PyTorch、torchvision、numpy 等;导出和验证都能用 CPU,GPU 只在跑 .pt 基线时更快。
  • onnxruntime 装 CPU 版即可。要测 GPU INT8,需要另外装 onnxruntime-gpu 并确认 CUDA/TensorRT EP。
  • 这个环境大约 6.5 GB,主要体积在 PyTorch。
验证环境
python 复制代码
import torch, ultralytics, onnx, onnxruntime as ort
print("torch:", torch.__version__, "cuda:", torch.cuda.is_available())
print("ultralytics:", ultralytics.__version__)
print("onnx:", onnx.__version__)
print("onnxruntime:", ort.__version__, ort.get_available_providers())

数据准备与 ONNX 模型导出

数据集

COCO128 是 Ultralytics 提供的一个 COCO 2017 小子集:

  • 128 张图,百张级别,只有 7 MB;
  • 自带 YOLO 格式标注,80 个 COCO 类别,和 YOLOv8n 的训练类别完全对齐;
  • 足够做一次「标定 + 验证」的完整演示,但不足以得出统计上很稳的结论。

如果要做真实业务评估,需换成自己的数据,或从 COCO val2017 里抽 300--500 张。

数据源:https://github.com/ultralytics/assets/releases/download/v0.0.0/coco128.zip

  1. curl -L --retry 5 --retry-all-errors 下载;
  2. zipfile.is_zipfile() 校验,不合法就重试。
python 复制代码
for attempt in range(3):
    subprocess.run([
        "curl", "-L", "--fail", "--retry", "5", "--retry-all-errors",
        "--connect-timeout", "20", "-o", str(tmp), URL,
    ])
    if zipfile.is_zipfile(tmp):
        break
else:
    raise RuntimeError("download failed")

解压后整理到 pictures/:

latex 复制代码
pictures/
├── coco128.zip                      # 原始压缩包(7.0 MB)
└── coco128/
    ├── images/train2017/            # 128 张 JPG
    ├── labels/train2017/            # 128 个 YOLO txt
    ├── splits/calib/{images,labels} # 64 张标定集(符号链接)
    ├── splits/val/{images,labels}   # 64 张验证集(符号链接)
    ├── coco128_split.yaml           # 本次使用的数据配置
    ├── coco128.yaml                 # 全量 128 张配置
    └── README.md

images/ 和 labels/ 保持 COCO128 原始结构;splits/ 用符号链接指向具体文件,不复制数据。

量化最忌讳标定集和验证集重叠:模型在标定图上看过激活分布,再拿同一批图评估,mAP 会偏乐观。所以必须切分。次按 64 / 64 切,随机种子固定为 42:

  1. 统计每个类别的出现次数;
  2. 把图片按「图中最稀有类别的频次」排序;
  3. 依次分配,优先给当前该类实例更少的一侧;
  4. 最后再平衡到 64 / 64。
python 复制代码
rng = random.Random(42)
rng.shuffle(items)
items.sort(key=lambda t: min(class_freq[c] for c in t[2]))  # 稀有类优先

calib, val = [], []
seen = {0: {}, 1: {}}
for img, lab, classes in items:
    rare = min(classes, key=lambda c: class_freq[c]) if classes else None
    if rare is None:
        target = 0 if len(calib) <= len(val) else 1
    else:
        c0, c1 = seen[0].get(rare, 0), seen[1].get(rare, 0)
        target = 0 if c0 <= c1 else 1
    (calib if target == 0 else val).append((img, lab, classes))
    for c in classes:
        seen[target][c] = seen[target].get(c, 0) + 1

while len(calib) > 64:
    val.append(calib.pop())
while len(calib) < 64:
    calib.append(val.pop())

最终:

latex 复制代码
calib = 64 张,覆盖 68 类
val   = 64 张,覆盖 65 类,429 个 GT 实例
两类共享 62 类

数据配置 yaml

Ultralytics 的 data.yaml 至少要有 path、train、val、names:

yaml 复制代码
path: /home/xugd/llm/cv-lab/pictures/coco128
train: splits/calib/images
val: splits/val/images
names:
  0: person
  1: bicycle
  2: car
  # ... 共 80 类
  79: toothbrush

几个容易踩的点:

  • path 建议写绝对路径,避免从不同目录运行时找不到数据;
  • train / val 指向图片目录,Ultralytics 会自动把 /images/ 替换成 /labels/ 找标签;
  • names 的顺序必须和模型训练时一致。顺序错了不会报错,但 mAP 会莫名其妙地低;

导出 ONNX

先把 FP32 基线做对,导出代码

python 复制代码
from ultralytics import YOLO

model = YOLO("models/yolov8n.pt")
exported = model.export(
    format="onnx",
    imgsz=640,
    opset=17,
    simplify=False,   # 不依赖 onnxslim/onnxsim
    dynamic=False,    # 静态 1×3×640×640
    device="cpu",     # 导出是图追踪,CPU 稳定
)

参数说明:

  • imgsz=640:和训练/验证一致。改尺寸会改变 anchor 数量和精度,量化前不要引入额外变量。
  • opset=17:覆盖面广,ONNX Runtime 1.31 支持良好;opset 18/19 也可以,但没必要追新。
  • dynamic=False:静态 shape 对量化最友好。动态轴会让激活范围随输入变化,校准和部署都更麻烦。
  • simplify=False:少一个 onnxslim 依赖。想要更干净的图,可以 pip install onnxslim 后改 True,但记得重新验证。
  • device="cpu":导出只是图追踪,CPU 足够,也避免 GPU 环境差异。

导出后会得到 models/onnx/yolov8n.onnx,12.82 MB。

输出张量长什么样

latex 复制代码
input:  images   float32  [1, 3, 640, 640]
output: output0  float32  [1, 84, 8400]
  • 84 = 4 + 80:前 4 个是框的 cx, cy, w, h,后 80 个是类别分数;
  • 8400 = 80×80 + 40×40 + 20×20:三个尺度(下采样)检测之和;
检测头 stride 特征图大小 网格位置数 主要检测
P3 / 浅层 8 640/8 = 80 → 80×80 80×80 = 6400 小目标
P4 / 中层 16 640/16 = 40 → 40×40 40×40 = 1600 中目标
P5 / 深层 32 640/32 = 20 → 20×20 20×20 = 400 大目标
  • 输出已经是 sigmoid 后的类别概率,不需要再套一层激活。

导出后立刻验证:不要等到量化完才发现导出错了

python 复制代码
import onnx
onnx.checker.check_model("models/onnx/yolov8n.onnx")

import onnxruntime as ort, numpy as np
sess = ort.InferenceSession("models/onnx/yolov8n.onnx",
                            providers=["CPUExecutionProvider"])
x = np.random.rand(1, 3, 640, 640).astype(np.float32)
y = sess.run(None, {sess.get_inputs()[0].name: x})[0]
print(y.shape, y.min(), y.max())

再用 Ultralytics 在同一验证集上跑一遍 FP32 ONNX,和 .pt 对比:

模型 mAP@50 mAP@50-95
PyTorch FP32 (.pt) 0.6182 0.4722
ONNX FP32 0.6184 0.4713

差距只有 0.0009 mAP@50-95,说明导出没有引入可感知的损失。

静态 INT8 量化实战与调优

静态 INT8 量化

ONNX Runtime 的 quantize_static 需要一个 CalibrationDataReader,每次返回一个 {input_name: ndarray}。预处理必须和推理时完全一致:

python 复制代码
class LetterboxCalibReader(CalibrationDataReader):
    def __init__(self, image_paths, input_name, imgsz=640):
        from ultralytics.data.augment import LetterBox
        self.paths = list(image_paths)
        self.input_name = input_name
        self.letterbox = LetterBox((imgsz, imgsz), auto=False, stride=32)
        self.index = 0

    def preprocess(self, path):
        im0 = cv2.imread(str(path))
        im = self.letterbox(image=im0)              # 保持比例,灰边填充
        im = im[:, :, ::-1].transpose(2, 0, 1)      # BGR→RGB, HWC→CHW
        im = np.ascontiguousarray(im, dtype=np.float32) / 255.0
        return im[None, ...]                        # 1×3×640×640

    def get_next(self):
        if self.index >= len(self.paths):
            return None
        path = self.paths[self.index]
        self.index += 1
        return {self.input_name: self.preprocess(path)}

关注点:

  1. LetterBox 而不是直接 resize:直接拉伸会改变长宽比,标定分布和验证分布不一致;
  2. BGR→RGB、/255:和 Ultralytics 推理管线一致,少一步都会让激活范围偏掉;
  3. batch=1:静态量化对 batch 不敏感,batch=1 最省内存,也最贴近部署。

调用 quantize_static

python 复制代码
from onnxruntime.quantization import (
    quantize_static, QuantFormat, QuantType, CalibrationMethod,
)

quantize_static(
    model_input="models/onnx/yolov8n.onnx",
    model_output="models/onnx/yolov8n_int8.onnx",
    calibration_data_reader=reader,
    quant_format=QuantFormat.QDQ,
    per_channel=True,
    reduce_range=False,
    activation_type=QuantType.QUInt8,
    weight_type=QuantType.QInt8,
    calibrate_method=CalibrationMethod.MinMax,
    extra_options={
        "ActivationSymmetric": False,
        "WeightSymmetric": True,
        "CalibMovingAverage": True,
    },
)
参数 作用 说明
quant_format=QDQ 显式插入 Quantize/Dequantize 后端兼容性好,方便调试
per_channel=True 卷积权重逐通道算 scale 比 per-tensor 精度更好,几乎无额外成本
activation_type=QUInt8 激活用无符号 8 bit 激活多为非负,uint8 利用率高
weight_type=QInt8 权重用有符号 8 bit 权重有正有负,int8 是标准做法
calibrate_method=MinMax 用校准集 min/max 定 scale 简单、稳定;数据干净时够用
CalibMovingAverage=True 校准统计做滑动平均 减少极端 batch 的影响

复制 metadata

QDQ 重写会丢掉 Ultralytics 写在 ONNX 里的自定义 metadata。没有这些字段,YOLO("xxx.onnx") 可能读不到类别名、输入尺寸或 stride,val() 直接报错或结果异常。

python 复制代码
import onnx

src = onnx.load("models/onnx/yolov8n.onnx", load_external_data=False)
dst = onnx.load("models/onnx/yolov8n_int8.onnx", load_external_data=False)

have = {p.key for p in dst.metadata_props}
for prop in src.metadata_props:
    if prop.key not in have:
        dst.metadata_props.add().CopyFrom(prop)

onnx.save(dst, "models/onnx/yolov8n_int8.onnx")
onnx.checker.check_model("models/onnx/yolov8n_int8.onnx")

复制完 metadata,再用 ORT 跑一张图确认输入输出:

latex 复制代码
FP32: input=(1, 3, 640, 640)  output=(1, 84, 8400)
INT8: input=(1, 3, 640, 640)  output=(1, 84, 8400)

踩坑实录

mAP 为什么会变成 0。

第一次量化完,直接跑 Ultralytics 验证:

latex 复制代码
all  64  429  0  0  0  0
mAP50-95=0.0000  mAP50=0.0000  P=0.0000  R=0.0000

模型能加载,能推理,输出 shape 也对,但一个检测框都没有。

定位:直接看原始输出。同一张图,分别喂给 FP32 和 INT8:

python 复制代码
for f in ["yolov8n.onnx", "yolov8n_int8.onnx"]:
    sess = ort.InferenceSession(f, providers=["CPUExecutionProvider"])
    y = sess.run(None, {sess.get_inputs()[0].name: x})[0]
    cls = y[0, 4:, :]   # 80 类分数
    box = y[0, :4, :]   # 框坐标
    print(f, "box max", box.max(), "cls max", cls.max(),
          "n>0.25", (cls > 0.25).sum())

结果非常明确:

latex 复制代码
FP32: box max 636.59  cls max 0.901171  n>0.25 37
INT8: box max 637.89  cls max 0.000000  n>0.25 0

框坐标还在,类别分数全变 0。这不是模型坏了,是输出被量化"压死"了。

原因:一个 Concat 混了两种量纲。看 ONNX 图的最后一个节点:

latex 复制代码
OUTPUT NODE: Concat  /model.22/Concat_3
inputs = ['/model.22/Mul_2_output_0', '/model.22/Sigmoid_output_0']
outputs = ['output0']
  • Mul_2 是框坐标,范围约 0--640;
  • Sigmoid 是类别概率,范围 0--1;
  • Concat 把它们拼成 1×84×8400。

默认量化会把这个 Concat 的输出也量化,并且用 per-tensor scale。校准集上框坐标最大约 637,于是:

\\text{scale} \\approx 637 / 255 \\approx 2.5

也就是说,量化后的一个整数步长代表约 2.5 个浮点值。类别概率全在 0--1 之间,连半个步长(约 1.25)都不到,四舍五入后全部变成 0。

框坐标因为本身数值大,量化误差 ~2.5 px 还能接受;类别分数却需要 0.004 级的分辨率。两种量纲共用一个 scale,必然有一方被牺牲。

修复:实际脚本不写死节点名,而是自动找出「产出 graph output 的节点」,这样换模型/换版本也不会失效:

python 复制代码
import onnx

m = onnx.load("models/onnx/yolov8n.onnx", load_external_data=False)
graph_outputs = {o.name for o in m.graph.output}
out_nodes = [n.name for n in m.graph.node if set(n.output) & graph_outputs]
print(out_nodes)   # ['/model.22/Concat_3']

然后把 out_nodes 传给量化器,并加上禁止量化 Concat 输出的保险:

python 复制代码
nodes_to_exclude=out_nodes,
extra_options={
    "OpTypesToExcludeOutputQuantization": ["Concat"],
    ...
}
  • nodes_to_exclude=out_nodes 让输出节点保持浮点;
  • OpTypesToExcludeOutputQuantization 是更通用的保险:任何 Concat 的输出都不量化。

这样两条输入分支各自量化:

  • 框分支:scale 按 0--640 算,精度 ~2.5 px;
  • 类别分支:scale 按 0--1 算,精度 ~0.004;

然后再由浮点 Concat 合并,输出保持 float32。

所有「框 + 类别」共用输出张量的检测模型(YOLO 系列、部分 SSD)都可能遇到。输出张量里如果有量纲差异巨大的字段,就别整体量化。

还能怎么优化

如果 0.055 的 mAP@50-95 损失不可接受,可以按成本从低到高尝试:

  1. 扩充校准集到 300--500 张,覆盖更多场景、光照、尺度。校准集越接近真实分布,scale 越准。
  2. 换校准方法:MinMax 容易被离群值拖累,可以试 Percentile(如 99.99%)或 Entropy。通常能挽回 1--3 个 mAP 点。
  3. 混合精度:对掉点严重的类别相关层保留 FP16/FP32,只量化主干。ORT 的 nodes_to_exclude 可以按名字排除,但需要逐层实验。
  4. QAT:量化感知训练,精度最好,成本也最高。YOLOv8 社区有一些 QAT 实践,但需要改训练代码。
  5. 换后端:NVIDIA GPU 上用 TensorRT INT8,通常比 ORT CPU 更快;x86 上确认 CPU 是否支持 VNNI/DP4A,支持的话 INT8 加速比会更高。
  6. 重标阈值:INT8 置信度整体低约 0.10,部署前在目标数据上重新扫一遍 conf 阈值,别直接沿用 FP32 的 0.25。
  7. 按类别设阈值:对掉点严重的类别降低阈值,或对这些类别保留 FP32 分支。

附录

Q1:量化后 mAP=0,但模型能跑,是什么原因?

先看输出张量的类别分数是不是全 0。大概率是输出 Concat 被整体量化了:框坐标 0--640 和类别分数 0--1 共用一个 scale,类别分数全部被舍入到 0。解决:nodes_to_exclude 排除输出节点,并设置 OpTypesToExcludeOutputQuantization=["Concat"]。

Q2:为什么不用动态量化?

动态量化在运行时算激活 scale,适合 RNN/Transformer;卷积网络的计算量集中在 Conv,静态量化才能用上 INT8 的整数卷积加速。动态量化在 CPU 上对 YOLO 的收益通常很小。

Q3:64 张校准图够吗?

对演示够了,对生产不够。校准集要覆盖真实分布:类别、尺度、光照、遮挡。建议 300--500 张起步;如果数据分布很宽,甚至要上千张。

Q4:INT8 一定比 FP32 快吗?

不一定。要看 CPU 是否支持 VNNI/DP4A、模型是否 memory-bound、线程数是否合理。本次 16 线程下纯推理 1.87 \\times ,但如果预处理占比高,端到端只有1.61,但如果预处理占比高,端到端只有 1.61,但如果预处理占比高,端到端只有1.61 \\times 。上目标硬件实测才算数。

Q5:输出还是 float32,算真正量化了吗?

算。INT8 量化的是内部卷积/矩阵乘的权重和激活;输出反量化回 float 是标准做法。部署时只有图内部用 INT8,接口仍然是 float,调用方无感知。

Q6:为什么 ONNX FP32 比 .pt 大?

.pt checkpoint 的权重以 FP16 保存,ONNX 导出默认 FP32,所以 ONNX 文件大约翻倍。比较压缩比时用「ONNX FP32 → ONNX INT8」,不要用 .pt 当分母。

Q7:能不能在 GPU 上跑 INT8?

可以,但需要 onnxruntime-gpu + TensorRT/CUDA EP,或直接转 TensorRT。

相关推荐
城南皮卡丘1 天前
苹果果实成熟度检测数据集 4195张+YOLO格式
深度学习·yolo·目标检测·计算机视觉·数据集
微尘hjx1 天前
【数据集 11】面向城市道路的坑洞检测数据集(含8760张,已按比例划分训练、验证、测试集,支持YOLO全系列训练)
人工智能·yolo·目标检测·数据集·道路坑洞·路坑数据集·路坑
土星云SaturnCloud3 天前
基于 SAM2 的视觉自动标注平台:从数据整理、微调训练到土星云 SE110S 系列边缘部署
边缘计算·模型量化·sam2
William一直在路上3 天前
把轻量化 YOLO 模型部署到你的手机上
yolo·智能手机
迪菲赫尔曼4 天前
【基础篇 11】把官方源码放到服务器:版本、解压层级和安装位置,一次弄清楚
运维·服务器·开发语言·人工智能·python·yolo·目标检测
迪菲赫尔曼4 天前
【基础篇 12】把环境与源码接起来:检查 GPU,再安装服务器上的项目
运维·服务器·开发语言·人工智能·python·yolo·目标检测
yyk333244 天前
YOLOv4算法
人工智能·yolo·目标跟踪
pjj198544 天前
YOLO 目标检测算法系列梳理:从 V1 到 V5 的原理演进
算法·yolo·目标检测
爱吃火鸡面呀4 天前
YOLOv2、YOLOv3、YOLOv4、YOLOv5 全面对比与总结
yolo