在前面的章节中,介绍了如何自建数据集,如何复现深度学习模型,如何训练测试模型以及导出模型的基础知识,这节将详细介绍如何以YOLOv8为例,真正实现计算机视觉应用,并用NCNN在arm架构芯片上进行加速推理。
分割的数据集就不再过多介绍了,我在下面这篇博客里做了详细的介绍,简单来说就是收集图像,用labelme标注图像,转成yolov8-seg的格式,再划分训练集测试集就好了。
从0开始的视觉研究生涯(1)从数据集开始讲起(入门)-CSDN博客
然后复制新建一个数据集描述的catseg.yaml文件,可以参考yolov8自带的ultralytics/cfg/datasets文件夹中的yaml文件,最简单的格式如下
# Train/val/test sets as 1) dir: path/to/imgs, 2) file: path/to/imgs.txt, or 3) list: [path/to/imgs1, path/to/imgs2, ..]
path: catdataset # dataset root dir
train: images/train # train images (relative to 'path') 128 images
val: images/val # val images (relative to 'path') 128 images
test: # test images (optional)
# Classes
names:
0: cat
然后再新建一个train-seg.py脚本,非常简单,读取描述模型的yolov8n-seg.yaml,n表示是nano,还可以选择其他大小的模型,再加载一下预训练权重,可以在官方github上下载
ultralytics/ultralytics: NEW - YOLOv8 in PyTorch > ONNX > OpenVINO > CoreML > TFLite (github.com)
from ultralytics import YOLO
# Load a model
model = YOLO('ultralytics/cfg/models/v8/yolov8n-seg.yaml') # build a new model from YAML
model = YOLO('yolov8n-seg.pt') # load a pretrained model (recommended for training)
# Train the model
results = model.train(data='cat-seg.yaml', epochs=100, imgsz=256)
val:YOLOv8训练以后会自动记住数据集等信息,所以验证时直接导入模型文件即可,这里seg的评价指标也是mAP
import os
from ultralytics import YOLO
# Load a model
#model = YOLO('yolov8n.pt') # load an official model
model = YOLO('runs/segment/train5/weights/best.pt') # load a custom model
# Validate the model
metrics = model.val() # no arguments needed, dataset and settings remembered
metrics.box.map # map50-95
metrics.box.map50 # map50
metrics.box.map75 # map75
metrics.box.maps # a list contains map50-95 of each category
predict同理
from ultralytics import YOLO
# Load a pretrained YOLOv8n model
model = YOLO('runs/segment/train5/weights/best.pt')
# Define path to directory containing images and videos for inference
source = 'smokedataset/datasets/catdataset/images/val'
# Run inference on the source
results = model(source, save=True,stream=True,hide_conf=True) # generator of Results objects
# Process results generator
for result in results:
boxes = result.boxes # Boxes object for bbox outputs
masks = result.masks # Masks object for segmentation masks outputs
keypoints = result.keypoints # Keypoints object for pose outputs
probs = result.probs # Probs object for classification outputs
导出onnx时,由于部分算子ncnn还不支持,所以需要对一些算子进行修改,这些算子全部在ultralytics/nn/modules目录下
首先是修改block.py中的c2f的forward
def forward(self, x):
# """Forward pass through C2f layer."""
# y = list(self.cv1(x).chunk(2, 1))
# y.extend(m(y[-1]) for m in self.m)
# return self.cv2(torch.cat(y, 1))
# !< https://github.com/FeiGeChuanShu/ncnn-android-yolov8
x = self.cv1(x)
x = [x, x[:, self.c:, ...]]
x.extend(m(x[-1]) for m in self.m)
x.pop(1)
return self.cv2(torch.cat(x, 1))
然后是修改head.py中的Detect的forward
def forward(self, x):
"""Concatenates and returns predicted bounding boxes and class probabilities."""
shape = x[0].shape # BCHW
for i in range(self.nl):
x[i] = torch.cat((self.cv2[i](x[i]), self.cv3[i](x[i])), 1)
if self.training:
return x
elif self.dynamic or self.shape != shape:
self.anchors, self.strides = (x.transpose(0, 1) for x in make_anchors(x, self.stride, 0.5))
self.shape = shape
x_cat = torch.cat([xi.view(shape[0], self.no, -1) for xi in x], 2)
return x_cat
# if self.export and self.format in ('saved_model', 'pb', 'tflite', 'edgetpu', 'tfjs'): # avoid TF FlexSplitV ops
# box = x_cat[:, :self.reg_max * 4]
# cls = x_cat[:, self.reg_max * 4:]
# else:
# box, cls = x_cat.split((self.reg_max * 4, self.nc), 1)
# dbox = dist2bbox(self.dfl(box), self.anchors.unsqueeze(0), xywh=True, dim=1) * self.strides
# if self.export and self.format in ('tflite', 'edgetpu'):
# # Normalize xywh with image size to mitigate quantization error of TFLite integer models as done in YOLOv5:
# # https://github.com/ultralytics/yolov5/blob/0c8de3fca4a702f8ff5c435e67f378d1fce70243/models/tf.py#L307-L309
# # See this PR for details: https://github.com/ultralytics/ultralytics/pull/1695
# img_h = shape[2] * self.stride[0]
# img_w = shape[3] * self.stride[0]
# img_size = torch.tensor([img_w, img_h, img_w, img_h], device=dbox.device).reshape(1, 4, 1)
# dbox /= img_size
# y = torch.cat((dbox, cls.sigmoid()), 1)
# return y if self.export else (y, x)
最后是修改segment的forward
def forward(self, x):
"""Return model outputs and mask coefficients if training, otherwise return outputs and mask coefficients."""
p = self.proto(x[0]) # mask protos
bs = p.shape[0] # batch size
mc = torch.cat([self.cv4[i](x[i]).view(bs, self.nm, -1) for i in range(self.nl)], 2) # mask coefficients
x = self.detect(self, x)
if self.training:
return x, mc, p
# return (torch.cat([x, mc], 1), p) if self.export else (torch.cat([x[0], mc], 1), (x[1], mc, p))
# !< https://github.com/FeiGeChuanShu/ncnn-android-yolov8
return (torch.cat([x, mc], 1).permute(0, 2, 1), p.view(bs, self.nm, -1)) if self.export else (torch.cat([x[0], mc], 1), (x[1], mc, p))
全部修改完毕以后用如下脚本导出onnx,这里推荐onnx的版本是12,yolov8目前推出了可以直接转ncnn的版本,但是目前还没有c++的example,在老方法可行的前提下,还是推荐用老方法。
from ultralytics import YOLO
# Load a model
# model = YOLO('yolov8n-seg.pt') # load an official model
model = YOLO('runs/segment/train6/weights/best.pt') # load a custom trained
# Export the model
# model.export(format='ncnn',half=True,imgsz=[160,120])
model.export(format='onnx', opset=12, simplify=True, dynamic=False, imgsz=[160,120])
这里先补充介绍一些基础知识
x86
、x64
、arm都指的是 CPU
的指令集架构。
X86是intel、AMD等PC CPU的架构,其一般指32
位架构处理器CPU,32位处理器,计算机中的位数指的是CPU一次能处理的最大位数。32位计算机的CPU一次最多能处理32位数据,例如它的EAX寄存器就是32位的,当然32位计算机通常也可以处理16位和8位数据。X64则是在x86的基础上发展而来的,64位处理器是采用64位处理技术的CPU,相对32位而言,64位指的是CPU GPRs(General-Purpose Registers,通用寄存器)的数据宽度为64位,64位指令集就是运行64位数据的指令,处理器一次运行64bit数据。所以可见x86编译的程序是可以在64位处理器上正常运行兼容的,但是X64编译的程序在x86上则不行,而从效率上看,x64程序显然是更具有效率的。
arm则以 ARM
公司的 arm
架构为代表。当前有 UNIX
、Linux
以及包括 iOS
、Android
、Windows Phone
等在内的大多数移动操作系统运行在精简指令集的处理器上。arm架构同样分64位处理器和32位处理器,不过目前市场上主流还是32位的。
而arm架构和X86X64架构是不兼容的。所以我们如果需要部署在单片机或者手机上,通常需要重新编译。
Debug:Debug 通常称为调试版本,通过一系列编译选项的配合,编译的结果通常包含调试信息,而且不做任何优化,以为开发人员提供 强大的应用程序调试能力。
Release:Release通常称为 发布版本,是为用户使用的,一般客户不允许在发布版本上进行调试。所以不保存调试信息,同时,它往往进行了各种优化,以期达到代码最小和速度最优。为用户的使用提供便利。
再补充介绍一些makefile和cmake的基础知识,毕竟咱们是从0开始转计算机视觉这个坑,知道是这俩是干啥的即可
makefile普通人甚至windows程序员用到的都比较少,原因是有很多IDE比如vs都有编译功能,但对于大工程项目或者其他设备上的程序就需要用makefile来编译,说到底,一个工程中的源文件不计数,其按类型、功能、模块分别放在若干个目录中,makefile定义了一系列的规则来指定,哪些文件需要先编译,哪些文件需要后编译,哪些文件需要重新编译,甚至于进行更复杂的功能操作,因为makefile就像一个Shell脚本一样,其中也可以执行操作系统的命令。makefile带来的好处就是——“自动化编译”,一旦写好,只需要一个make命令,整个工程完全自动编译,极大的提高了软件开发的效率。make是一个命令工具,是一个解释makefile中指令的命令工具,一般来说,大多数的IDE都有这个命令,比如:Delphi的make,Visual C++的nmake,Linux下GNU的make。可见,makefile都成为了一种在工程方面的编译方法。
CMake则是自动生成makefile的工具,是一个跨平台的编译(Build)工具,可以用简单的语句来描述所有平台的编译过程。CMake能够输出各种各样的makefile或者project文件,能测试编译器所支持的C++特性,类似UNIX下的automake。
言归正传,我们现在就要编译NCNN了。首先第一步就是打开NCNN的基础教程,以Windows为例,我们需要下载安装VS2019,然后打开x64 Native Tools Command Prompt for VS 2019如果要编译x86版本的则打开x86 Native Tools Command Prompt for VS 2019,打开之后会有命令行窗口
Visual studio和CMake安装 - luozhifu - 博客园 (cnblogs.com)
windows10+vs2019 安装cmake步骤_vs安装cmake_图像僧的博客-CSDN博客
在安装VS时请记得一起安装Cmake,或者再单独下载安装cmake。
然后我们需要先编译protobuf,大部分推理库都依赖这个,Google Protocol Buffer( 简称 Protobuf) 是 Google 公司内部的混合语言数据标准,目前已经正在使用的有超过 48,162 种报文格式定义和超过 12,183 个 .proto 文件。他们用于 RPC 系统和持续数据存储系统。Protocol Buffers 是一种轻便高效的结构化数据存储格式,可以用于结构化数据串行化,或者说序列化。它很适合做数据存储或 RPC 数据交换格式。可用于通讯协议、数据存储等领域的语言无关、平台无关、可扩展的序列化结构数据格式。
https://github.com/google/protobuf/archive/v3.11.2.zip
我们再之前的命令行窗口内输入以下命令,
cd
mkdir protobuf_build
cd protobuf_build
cmake -A x64 -DCMAKE_INSTALL_PREFIX=%cd%/install -Dprotobuf_BUILD_TESTS=OFF -Dprotobuf_MSVC_STATIC_RUNTIME=OFF ../cmake
cmake --build . --config Release -j 2
cmake --build . --config Release --target install
然后如果要调用GPU的话则下载vulkan,Vulkan是一个低开销、跨平台的二维、三维图形与计算的应用程序接口(API),最早由科纳斯组织在2015年游戏开发者大会(GDC)上发表。直接去官网下载安装即可
Home | Vulkan | Cross platform 3D Graphics
接着就是编译NCNN了,如果只编译CPU版本则将-DNCNN_VULKAN=ON改为-DNCNN_VULKAN=OFF,此外还需要将protobuf的路径改为你自己安装的路径
cd
mkdir -p protobuf_build
cd protobuf_build
cmake -A x64 -DCMAKE_INSTALL_PREFIX=%cd%/install -Dprotobuf_DIR=/protobuf_build/install/cmake -DNCNN_VULKAN=ON ..
cmake --build . --config Release -j 2
cmake --build . --config Release --target install
这里编译x64是没有问题的,编译x86可能会出问题,可以换用如下命令行来编译,还有就是Debug和Release版本按需修改,同时编译x86版本需要x86版本的protobuf
cd
mkdir build-vs2019
cd build-vs2019
cmake -G"NMake Makefiles" -DCMAKE_BUILD_TYPE=Debug -DCMAKE_INSTALL_PREFIX=%cd%/install -Dprotobuf_BUILD_TESTS=OFF -Dprotobuf_MSVC_STATIC_RUNTIME=OFF ../cmake
nmake
nmake install
cd
mkdir build
cd build
cmake -G"NMake Makefiles" -DCMAKE_BUILD_TYPE=Debug -DCMAKE_INSTALL_PREFIX=%cd%/install -DProtobuf_INCLUDE_DIR=D:/ncnnx86debug/protobuf-3.4.0/build-vs2019/install/include -DProtobuf_LIBRARIES=D:/ncnnx86debug/protobuf-3.4.0/build-vs2019/install/lib/libprotobufd.lib -DProtobuf_PROTOC_EXECUTABLE=D:/ncnnx86debug/protobuf-3.4.0/build-vs2019/install/bin/protoc.exe -DNCNN_VULKAN=off ..
nmake
nmake install
如果完全按照官方教程,你会编译得到一个CPU版本的64位release的NCNN,如果按照了Vulkan并修改了-DNCNN_VULKAN=ON,则会编译得到可以使用GPU的64位release的NCNN,总而言之,可以分为4类NCNN,按需编译即可
1、64位CPU Release/debug
2、64位GPU Release/debug
3、32位CPU Release/debug
4、32位GPU Release/debug
model.export(format='onnx', opset=12, simplify=True, dynamic=False, imgsz=[160,120])
前文里获得了编译以后的ncnn和yolov8的导出onnx文件,可以直接用这个网站进行onnx转ncnn(推荐)
一键转换 Caffe, ONNX, TensorFlow 到 NCNN, MNN, Tengine (convertmodel.com)
也可以用ncnn编译得到的onnx2ncnn进行转换(不推荐)
./onnx2ncnn cat.onnx cat.param cat.bin
目前ncnn官方推荐使用pnnx直接对pytorch文件进行格式转换,直接下载开箱即用(推荐)
Releases · pnnx/pnnx (github.com)
pnnx mobilenet_v2.pt "inputshape=[1,3,224,224]"
在转换过程中,会有一些参数选择, 比如simplify,官方对onnx-simplify的解释如下
ONNX Simplifier is presented to simplify the ONNX model. It infers the whole computation graph and then replaces the redundant operators with their constant outputs (a.k.a. constant folding).
简单来说就是将一些复杂的计算转换成相对简单的算子,默认使用即可。
dynamic指的是输入输出的向量维度是否可变,比如我想一次性输入多张图像,就可以设置dynamic_axes为0维的batch_size,而在yolov8中直接设置dynamic=true即可改动所有的输入维度,不确定输入大小的话默认为true即可
torch.onnx.export(torch_model, # model being run
x, # model input (or a tuple for multiple inputs)
"super_resolution.onnx", # where to save the model (can be a file or file-like object)
export_params=True, # store the trained parameter weights inside the model file
opset_version=10, # the ONNX version to export the model to
do_constant_folding=True, # whether to execute constant folding for optimization
input_names = ['input'], # the model's input names
output_names = ['output'], # the model's output names
dynamic_axes={'input' : {0 : 'batch_size'}, # variable lenght axes
'output' : {0 : 'batch_size'}})
#YOLOv8的dynamic参数,在ultralytics/engine/exporter.py文件中
if dynamic:
dynamic = {'images': {0: 'batch', 2: 'height', 3: 'width'}} # shape(1,3,640,640)
if isinstance(self.model, SegmentationModel):
dynamic['output0'] = {0: 'batch', 2: 'anchors'} # shape(1, 116, 8400)
dynamic['output1'] = {0: 'batch', 2: 'mask_height', 3: 'mask_width'} # shape(1,32,160,160)
elif isinstance(self.model, DetectionModel):
dynamic['output0'] = {0: 'batch', 2: 'anchors'} # shape(1, 84, 8400)
imgsz=[160,120]对onnx导出似乎没什么影响,默认读取原模型的输入大小,一般会把输入图像的大小resize为32的倍数,所以一张320,240的图像最终会resize为160,128
half选项等同于产生fp16模型,这里介绍一下fp32,fp16以及int8的基础知识
LLM大模型之精度问题(FP16,FP32,BF16)详解与实践 - 知乎 (zhihu.com)
FP16也叫做 float16,两种叫法是完全一样的,全称是Half-precision floating-point(半精度浮点数),在IEEE 754标准中是叫做binary16,简单来说是用16位二进制来表示的浮点数。
BF16也叫做bfloat16(这是最常叫法),其实叫“BF16”不知道是否准确,全称brain floating point,也是用16位二进制来表示的,是由Google Brain开发的,所以这个brain应该是Google Brain的第二个单词。和上述FP16不一样的地方就是指数位和尾数位不一样。
FP32也叫做 float32,两种叫法是完全一样的,全称是Single-precision floating-point(单精度浮点数),在IEEE 754标准中是叫做binary32,简单来说是用32位二进制来表示的浮点数。
从效率上讲FP16>FP32,BF16适合在GPU情况下使用。转换方法见下
fp32和fp16之间转换_fp32转fp16-CSDN博客
INT8与FP16、FP32的优势在于计算的数据量相对小,计算速度可以更快,并且能通过减少计算和内存带宽需求来提高能耗。
一般来说转出模型的大小,FP32是FP16的两倍,FP16是int8的两倍,速度反过来,int8最快。
经过前文的操作我们已经获得了FP16的模型文件,cat.param和cat.bin,如果我们要进一步加速需要怎么做呢?
FP32与FP16之间的数据转换损失较少,但直接转换成int8则会带来 较大的损失
NCNN量化详解(二) - 知乎 (zhihu.com)
问题原因:
由于float32的取值范围几乎是无穷的,而int8只有-128~127。因此建立映射关系时,确定float32的取值范围很重要。
取指范围过大,则int8的精度会下降(想象一下,fp32的取值范围设置为了-1270.0~+1270.0,则0.0~+12.7里面的值,对应到int8都只是+1。若是真实数据就是这么分布的还好,但若是真实数据仅仅分布在-1.0~+1.0下,那么量化后所有的数据只有-1/0/+1三种,是不是精度很差?)
取指范围过小,则会截断过多的数据,导致分布靠两边的信息损失。
所以合理的思路是取一定范围的值建立映射关系
数据有偏差的情况,左边的方案是选最大的那个,右边是NVIDIA的算法,选择一个阈值T,达到精度和表示范围的trade-off,那么如何选择阈值T的值是最合理的呢?
假设量化前fp32的数据分布为P,量化后int8的数据分布为Q,那么只要让P和Q之间的KL散度越小,则表明量化前后的分布越接近,量化也就越有效啦
NCNN和NVIDIA TensorRT都以阈值T作为一个搜索的变量,每选择一个T,都去重新计算一下新阈值T下量化后的分布,然后计算一下KL散度。在计算了各个T下的KL散度后,选择令KL散度最小的那个T作为最终的T,从而确定Scale
1. 计算fp32数据的最大绝对值max_abs_value = (abs(min_value), abs(max_value))
2. 计算fp32数据的原始分布Po(P original)
值得一提的是,NCNN默认Po有2048个bins,即原始统计直方图每一个bin的长度interval = max_abs_value/2048
3. for num_bins from 128 to 2048:
3.1 反推阈值T = interval * num_bins,使用T截断Po,得到分布P,P有num_bins个桶
3.2 将P量化到Q中,Q的桶数为128
3.3 将Q扩展到和P一样的长度,得到分布Q_expand
3.4 计算P和Q_expand的KL散度,并判断是否为最小
原理听起来很复杂,总结来说就是需要图像数据来帮助选择截取的阈值,从而获得更好的int8效果。
那么具体操作就是先去数据集文件夹内,右键打开终端,用如下命令行生成图像的路径list文件
find images/ -type f > imagelist.txt //ubuntu系统
dir /B >> d:/imagelist.txt //windows系统
然后找到编译后的ncnn文件夹buildx64\tools\quantize\Release下的ncnn2table,用如下命令行编译,YOLOv8默认的图像mean就是mean=[104,117,123] norm=[0.017,0.017,0.017],可以直接使用,调整输入模型的shape即可
ncnn2table cat.param cat.bin imagelist.txt cat.table mean=[104,117,123] norm=[0.017,0.017,0.017] shape=[160,128,3] pixel=BGR thread=4 method=kl
再用 ncnn2int8
ncnn2int8 cat.param cat.bin cat-int8.param cat-int8.bin cat.table
ncnn-android-yolov8/ncnn-yolov8s-seg at main · FeiGeChuanShu/ncnn-android-yolov8 (github.com)
这里不能泄漏我本身的工程代码,所以推荐github大佬的教程,将加载模型换成自己的
static int detect_yolov8(const cv::Mat& bgr, std::vector
目前的NCNN在CPU上是自动默认开启fp16运算和存储,此外本人并没有找到ncnn net.opt的说明文档,所以以下加速方法为本人实验得到的,原理有些还不清楚
this->Net->opt.lightmode = true; //设置为轻量模式,不显著加速
this->Net->opt.use_packing_layout = true; //不显著加速
this->Net->opt.num_threads = 4; //设置多线程,显著加速
this->Net->opt.use_int8_inference = true;
this->Net->opt.use_int8_arithmetic = true;
//this->Net->opt.use_fp16_arithmetic = true; //注意将原来的fp16相关运算改为int8
经过以上研究,YOLOv8-seg在我的CPU上能达到100+FPS
非常麻烦,做一个经验总结,以后希望不会再碰了,都是好的工具,但是在商业化过程中,通常不愿意升级芯片会加大设备成本,此外数据也会有很多问题,这导致再怎么加速效果都不满足,当然做demo还是够用了,哎,资本家