YOLOv8 基于NCNN的安卓部署

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里可能还要改一下类别数和类别名,这个问题不大,大家可以自行探索。

上一篇:使用springCache实现缓存-配置


下一篇:并查集的模拟实现