YOLOv8 NCNN安卓部署
前两节我们依次介绍了基于YOLOv8的剪枝和蒸馏
剪枝:https://blog.****.net/qq_41335232/article/details/140823592
蒸馏:https://blog.****.net/qq_41335232/article/details/142717122
开源代码:li554/yolov8_deploy: yolov8训练、剪枝、蒸馏、ncnn安卓部署全流程
本节将上一节得到的蒸馏模型导出NCNN,并部署到安卓。
NCNN 导出
YOLOv8项目中提供了NCNN导出的接口,但是这个模型放到ncnn-android-yolov8项目中你会发现更换模型后app会闪退。原因我们后面说明,总之,在导出之前,我们需要做一些源代码修改。
第一步,修改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))
def forward(self, x):
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))
做上述修改的原因,GPT的回答大致意思是原始代码中chunk和list都是动态操作,而NCNN、ONNX这些都是静态图,所以最好不要出现这些操作。在我看来有些强行解释了。但是修改后的代码肯定比修改前的代码好的地方在于,少进行了一次split和merge的操作。因为我们遍历m计算的时候只使用了x的后半部分,所以我们可以只切片出后半部分用于计算,然后merge之前第一个元素是前半部分+后半部分,第二个元素是后半部分,所以我们pop(1)去掉这个后半部分之后再merge即可实现跟修改前等价的操作,同时少了一次chunk split和merge。
修改ultralytics/nn/modules/head.py中的Detect类的forward函数
# def forward(self, x):
# """Concatenates and returns predicted bounding boxes and class probabilities."""
# if self.end2end:
# return self.forward_end2end(x)
#
# for i in range(self.nl):
# x[i] = torch.cat((self.cv2[i](x[i]), self.cv3[i](x[i])), 1)
# if self.training: # Training path
# return x
# y = self._inference(x)
# return y if self.export else (y, x)
def forward(self, x):
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
pred = torch.cat([xi.view(shape[0], self.no, -1).permute(0, 2, 1) for xi in x], 1)
return pred
#def _inference(self, x):
# """Decode predicted bounding boxes and class probabilities based on multiple-level feature maps."""
# # Inference path
# shape = x[0].shape # BCHW
# x_cat = torch.cat([xi.view(shape[0], self.no, -1) for xi in x], 2)
# if 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
#
# 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)
#
# if self.export and self.format in {"tflite", "edgetpu"}:
# # Precompute normalization factor to increase numerical stability
# # See https://github.com/ultralytics/ultralytics/issues/7371
# grid_h = shape[2]
# grid_w = shape[3]
# grid_size = torch.tensor([grid_w, grid_h, grid_w, grid_h], device=box.device).reshape(1, 4, 1)
# norm = self.strides / (self.stride[0] * grid_size)
# dbox = self.decode_bboxes(self.dfl(box) * norm, self.anchors.unsqueeze(0) * norm[:, :2])
# else:
# dbox = self.decode_bboxes(self.dfl(box), self.anchors.unsqueeze(0)) * self.strides
#
# return torch.cat((dbox, cls.sigmoid()), 1)
这部分的修改就有一个大坑,我在这里卡了很长时间。我们先看看这段代码做了什么修改。
首先,原始的代码需要调用_inference函数,关于export啥的在我们推理的时候都不需要,所以只需看else分支即可。end2end我们也不需要。所以下面这部分代码是一致的:
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
这段代码之后呢,原始的代码有三步操作,即
box, cls = x_cat.split((self.reg_max * 4, self.nc), 1)
dbox = self.decode_bboxes(self.dfl(box), self.anchors.unsqueeze(0)) * self.strides
return torch.cat((dbox, cls.sigmoid()), 1)
前两行代码其实是在计算框的xywh四个值。而修改后的代码跳过了这个部分,直接cat,然后我们如果调用这个模型打印输出维度会发现他是一个1x8400x144的维度。而经过原始代码的输出是1x84x8400的维度。
84好理解,就是80个类别+4个bbox参数(x,y,w,h),由于YOLOv8去掉了置信度分支,所以一共就是84个值;
而144的含义其实是80+16x4,80依然表示类别数量,4表示bbox参数(lrtb),16来源于regmax的定义,表示区间分段大小。我们知道,YOLOv8的框参数的计算不是常规的回归方式,而是视作基于DFL的分类问题。也就是说我们可以认为边界框的参数一定在某个范围0-regmax之内,比如regmax为16的时候,在特征图上可以表示32的长度,还原到图像上为32x32 = 1024>640,所以对于640的输入,这个范围完全可以覆盖所有目标的尺度。然后YOLOv8会预测16个概率值,基于这个概率分布计算期望即为lrtb的长度,然后后续再转成xywh格式即可。
所以原始的代码就是多了上述这个边界框解码的过程。那么为什么要去掉呢?原因就是ncnn-android-yolov8这个项目中把这部分的后处理使用c++实现了。为什么用c++实现我的理解是这类后处理代码基本没法基于NCNN加速,使用c++实现会更加高效。
还有一个点我没有提到的是,为什么原本1x84x8400的维度,为什么修改后变成了1x8400x144,也就是发生了维度交换。这里就是一个大坑。首先我先说为什么要交换维度,因为ncnn-android-yolov8是基于交换维度之后的输出进行的,如果不加维度交换而是在c++里面基于ncnn编写维度交换的代码就比较麻烦了,首先ncnn::Mat类没有这一类的api实现,可能你要么基于ncnn创建一个Layer(“Permute”),要么就写for循环转移,总之,会很麻烦。所以我们尽可能在导出之前就完成这个维度的交换。
现在来看这段修改后的代码:
def forward(self, x):
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
pred = torch.cat([xi.view(shape[0], self.no, -1).permute(0, 2, 1) for xi in x], 1)
return pred
注意到在cat之前,对每一个xi我都进行了一遍permute交换维度。你可能会想,为什么要这样呢?我先cat完然后再permute不就只需要一次permute了吗。恭喜你,我开始也是这么想的,网上的教程也是这么想的,并且ncnn-android-yolov8中给出的修改示例还是这么想的。
然后你就会运行yolo.export导出ncnn并且测试了一下输出shape之后发现,维度还是1x144x8400,根本没有交换过来。
这里我可能还有一点东西没有介绍,就是ncnn的导出是这样的。
我们改完代码之后直接yolo.export(format=“ncnn”)就行了。然后会看到路径下生成这样一个文件夹
其中,model.ncnn.bin和model.ncnn.param都是模型推理需要加载的文件,model_ncnn.py是一个加载ncnn模型推理的测试脚本。
回到我刚才说的,发现输出不对的话,我们可以打开model.ncnn.param查看一下,这里面可以看到导出的模型的层结构。
然后可以看到,导出的模型在Concat之后就完了,Permute层没有导出。开始以为可能是不支持Permute操作,然后换成了Transpose也不行。就卡在这里很长时间,一度让我开始基于c++和ncnn考虑怎么在android端实现这个维度转换,但是最终还是放弃了,重新回到这个问题。
最后我在issues里面看到这个回答:
基于pnnx转出的模型会自动删除末尾的reshape和permute。破案了破案了。。。
其实网上给出的教程很多会给我们一个网站https://convertmodel.com,但是这个网站已经挂了,现在来看,这个网站走的模型导出路线肯定是pytorch->onnx->ncnn,而yolov8默认的路线是pytorch->torchscript->ncnn,其中,torchscript到ncnn的转换是通过pnnx实现的。这也就是为什么这些教程都没有遇到我这个问题。
那么基于上述问题,我们应该怎么办呢?既然pnnx会删除最后的permute的操作,那么我们能不能把permute提前呢?还好,是可以的,也就是我给出的修改代码。
好了,到了这一步就结束了吗?还没有,如果你兴高采烈地拿着导出地模型放到android studio,重新编译运行app,然后就会。。。闪退。
因为YOLOv8的推理是基于letterbox操作的,真实推理时模型的输入不会是标准的640x640,但是我们导出的时候是默认640x640的,并且只能接受这个尺寸。我不想在c++端强行转成640x640,这会影响模型性能。那么剩余的方案就是修改输入shape或者希望导出的模型支持动态shape了。我做的是后者,因为我考虑到app上推理图片的尺寸可能总是大小不一的。
还好,要实现动态shape操作比较简单,我们只需要手动修改一下model.ncnn.param文件即可
在这个文件的最后,我们可以看到有三个Reshape操作,关键就在这个Reshape操作了,我们只需要把6400,1600,400都改成-1即可
我解释一下这些数字的含义,以180行和181行为例:
1 1:这是输入和输出的数量。第一个数字是输入的数量,第二个数字是输出的数量。
189 212:这是输入和输出的索引。第一个数字是输入的索引,第二个数字是输出的索引。索引是根据参数文件中的顺序来确定的。
0=6400 1=144:这是层的参数。0=6400表示将输入重塑为6400个元素,1=144表示每个元素有144个通道。
对于180行的Permute层,0=1表示将通道维度移到最前面。
至此,NCNN模型导出完毕。
NCNN Android部署
前面我们提到多次ncnn-android-yolov8,这是一个基于开源库,实现了c++版基于ncnn的yolov8模型推理和后处理等操作,并且提供了一个简易的demo。
在开始之前,我们需要预先准备一些东西:
- ncnn-android-yolov8
- ncnn-20240410-android-vulkan
- opencv_mobile-2.4.13.7
- Android Studio
ncnn-android-yolov8我们下载解压之后只需要ncnn-android-yolov8这个文件夹即可。
此外,下载的ncnn-20240410-android-vulkan和opencv_mobile-2.4.13.7解压后放到ncnn-android-yolov8/app/src/main/jni文件夹下。如下:(我多下了一些版本,调试过程产物,实际用啥都行)
然后修改CMakeLists.txt中opencv和ncnn的路径
project(yolov8ncnn)
cmake_minimum_required(VERSION 3.10.1)
set(OpenCV_DIR ${CMAKE_SOURCE_DIR}/opencv-mobile-2.4.13.7-android/sdk/native/jni)
find_package(OpenCV REQUIRED core imgproc)
set(ncnn_DIR ${CMAKE_SOURCE_DIR}/ncnn-20240410-android-vulkan/${ANDROID_ABI}/lib/cmake/ncnn)
find_package(ncnn REQUIRED)
add_library(yolov8ncnn SHARED yolov8ncnn.cpp yolo.cpp ndkcamera.cpp)
target_link_libraries(yolov8ncnn ncnn ${OpenCV_LIBS} camera2ndk mediandk)
随后,你就可以打开Android Studio加载这个项目了。
我们现在需要额外的东西:
- cmake:3.10.2
- JDK:corretto-11
- SDK:30
- NDK:27.1
比较关键的是上面几个,这些都可以在Android Studio中下载,而无需单独下载配置。我之前过程中有遇到过一些版本问题,如果你发现自己的编译报错的话,试试跟我的版本保持一致。
还有一些别的版本,总之所有的版本设置都在下面了,剩下就是对Android Studio的探索了。
另外,如果你发现编译下载文件失败的话,在Android Studio设置proxy,同时本地打开全局代理。
差点忘了,还有一个地方要修改,如果我们导出ncnn的时候没有指定输入名和输出名的话,默认输入名是in0,输出名是out0,因此我们在c++里读取模型进行推理的时候也要用这个名字,但是ncnn-android-yolov8里面的名字写的images和output,如下(yolo.cpp文件300行):
ex.input("images", in_pad);
std::vector<Object> proposals;
ncnn::Mat out;
ex.extract("output", out);
将images改成in0,output改成out0即可。
此外,我们训练的模型可能类别和原始模型是不一样的,所以yolo.cpp里可能还要改一下类别数和类别名,这个问题不大,大家可以自行探索。