(三)ncnn | PaddleDetection + FCOS + Android Studio


1. 简介

本文以目标检测模型 FCOS 为例,详细介绍从使用 PaddleDetection 训练模型,到最终部署到移动端安卓设备的全流程。


2. PaddleDetection

项目地址:https://github.com/PaddlePaddle/PaddleDetection,PaddleDetection 项目结构非常简单(几个关键的目录):

PaddleDetection
├─ configs
├─ dataset
├─ ppdet
└─ tools

2.1 安装

不使用源码安装 ppdet 的话,在 PaddleDetection 根目录下直接安装依赖即可:

pip install -r requirements

编译安装 ppdet(可选):

python setup.py install

2.2 configs

configs 文件夹的内容以模型为基本单位,以 yml 作为文件格式,采用递归的方式配置模型训练的全部参数。如本文使用的目标检测模型 FCOS 相关的配置文件:

FCOS
├─ _base_
│    ├─ fcos_r50_fpn.yml
│    ├─ fcos_reader.yml
│    └─ optimizer_1x.yml
├─ fcos_dcn_r50_fpn_1x_coco.yml
├─ fcos_r50_fpn_1x_coco.yml
└─ fcos_r50_fpn_multiscale_2x_coco.yml

训练模型时,指定 FCOS 根目录下的 yml 即可。如 fcos_r50_fpn_1x_coco.yml 表示主干网络使用 ResNet50、颈部使用 FPN、训练周期为 1x、数据集使用 coco,后续训练模型时指定改配置文件即可。

2.3 dataset

dataset 文件夹的内容以数据集为基本单位,可以本地数据手动将放到对应文件夹或通过自带的脚本下载。注意,可以将自己的数据集转换为标准数据集的格式,然后放到对应目录即可。如果有将 voc 格式格式的数据集转换为 coco 数据集格式的需求,欢迎评论区交流。

2.4 ppdet

ppdet 是 PaddleDetection 的核心目录,里面完成串联和解析各配置文件、数据处理、模型训练等核心功能。以下是几个重要的目录:

ppdet
├─ data
├─ engine
├─ modeling
│    ├─ architectures
│    ├─ backbones
│    ├─ heads
│    ├─ losses
│    └─ necks
└─ utils

data 目录主要完成数据的处理,engine 目录主要完成模型的调度,modeling 目录主要完成模型的搭建,utils 目录完成一些通用功能。其中,如果想更改网络结构或添加新模块等,操作 modeling 文件夹内的对应目录即可。

2.5 tools

tools 目录主要完成模型训练、验证、测试的启动。主要选项的用法:

# -c 指定配置文件即可训练对应的模型,-r 选项用于恢复训练,--eval 用于开启训练时的验证
python tools/train.py -c configs/fcos/fcos_r50_fpn_1x_coco.yml

# -c 指定配置文件即可验证对应的模型
python tools/eval.py -c configs/fcos/fcos_r50_fpn_1x_coco.yml

# -c 指定配置文件即可测试对应的模型,--infer_img 指定测试图像
python tools/infer.py -c configs/fcos/fcos_r50_fpn_1x_coco.yml --infer_img=demo.jpg

在模型的验证和测试时,模型路径由配置文件的 weights 关键字指定,也可以使用 -o weights=xxx 手动指定。PaddleDetection 的其他详细内容请见,PaddleDetection 中文文档


3. FCOS 后处理裁剪

在导出模型前,由于 ncnn 不支持 FCOS 的后处理,所以在导出前将其删除。① 删除配置文件 configs/fcos/_base_/fcos_r50_fpn.yml 的后处理部分:

FCOSPostProcess:
  decode:
    name: FCOSBox
    num_classes: 18
  nms:
    name: MultiClassNMS
    nms_top_k: 1000
    keep_top_k: 100
    score_threshold: 0.025
    nms_threshold: 0.6

② 修改后处理 ppdet/modeling/post_process.py 的返回结果:

def __call__(self, fcos_head_outs, scale_factor):
    locations, cls_logits, bboxes_reg = fcos_head_outs
    bboxes, score = self.decode(locations, cls_logits, bboxes_reg, scale_factor)
    bbox_pred, bbox_num, _ = self.nms(bboxes, score)
    return bbox_pred, bbox_num
# 修改为===>
def __call__(self, fcos_head_outs, scale_factor):
    locations, cls_logits, bboxes_reg = fcos_head_outs
    return cls_logits, bboxes_reg, centerness

③ 修改网络颈部 ppdet/modeling/architectures/fcos.py 的返回结果:

def get_pred(self):
    bbox_pred, bbox_num = self._forward()
    output = {'bbox': bbox_pred, 'bbox_num': bbox_num}
    return output
# 修改为===>
def get_pred(self):
    cls_logits, bboxes_reg, centerness = self._forward()
    output = {'cls': cls_logits, 'bbox': bboxes_reg, 'centerness': centerness}
    return output

4. Paddle 模型转 onnx

PaddleDetection 完成后得到一个 pdparams 和 pdopt 文件,前者存放了模型权重、后者存放用于断点训练的参数等。Paddle 提供了与其框架衔接的部署套件,Paddle-Lite 仓库,但本人还没有尝试过,所以最终以 onnx 为中间件。PaddleDetection 中提供了模型导出脚本:

python tools/export_model.py -c configs/fcos/fcos_r50_fpn_1x_coco.yml 
							 -o weights =<path-to-weights> 
							 TestReader.inputs_def.image_shape=[n,c,h,w] 
					         --output_dir <out-dir>

执行命令后得到后续使用 pdparams 文件和 pdiparams 文件,然后使用 paddle2onnx 转换,首先使用 pip 安装即可:

pip install paddle2onnx

然后执行转换:

paddle2onnx --model_dir <dir> --model_filename model.pdmodel --params_filename model.pdiparams 
		    --opset_version 11 --save_file fcos.onnx

Paddle 模型转换为 onnx 格式可参考,Paddle 模型导出教程


5. onnx 转 ncnn

5.1 protobuf

git clone https://github.com/protocolbuffers/protobuf.git

首先编译 protobuf,为避免编译子模块时出错,首先修改 .gitmodules 下的地址,然后执行:

git submodule update --init --recursive

生成配置文件 configure:

./autogen.sh

编译时设置产生的文件的存放地址:

./configure --prefix=<install-path>

依次执行

make
make check
make install

5.2 ncnn

git clone https://github.com/Tencent/ncnn.git

首先编译 ncnn 得到转换工具。为避免编译子模块时出错,首先修改 .submodule 和 .git 目录下 config 文件中的地址,然后执行:

git submodule update --init
cmake ..
make

期间如果找不到 protobuf,则在 tools/onnx/CMakeLists.txt 中添加:

set(Protobuf_LIBRARIES <protobuf-dir>/lib/libprotobuf.so)
set(Protobuf_INCLUDE_DIR <protobuf-dir>/include)

编译完成后,得到转换工具 build/tools/onnx2ncnn。在转换前,首先使用 onnxsim 精简模型,使用 pip 安装即可:

pip install onnxsim

然后执行:

python -m onnxsim fcos.onnx. fcossim.onnx --input-shape n,c,h,w

最后执行转换:

./onnx2ncnn fcossim.onnx fcos.param fcos.bin

即得到转换结果,下一步的 int8 量化步骤为可选。


6. ncnn 的 int8 量化(可选)

首先使用优化器优化模型,在目录 build/tools 下:

./ncnnoptimize fcos.param fcos.bin fcos_opt.param fcos_opt.bin 0

首先使用 ImageNet 的部分数据集 calibration 生成校准列表:

find calibration/ -type f > imagelist.txt

然后使用 build/tools/quantize 下的工具生成量化表:

./ncnn2table fcos_opt.param fcos_opt.bin imagelist.txt fcos.table mean=[x,x,x] norm=[x,x,x] 
			 shape=[h,w,c] pixel=BGR thread=2 method=kl

最后,得到量化模型:

./ncnn2int8 fcos_opt.param fcos_opt.bin fcos_int8.param fcos_int8.bin fcos.table

7. 基于 x86 平台的推理

在进行最后的部署前,由于本人不熟悉安卓项目的构建,所以首先在 Linux 平台模拟一遍模型的推理,仓库地址。项目结构为:

demo_ncnn
├─ include
├─ lib
└─ nets
   └─ fcos
      ├─ CMakeLists.txt
      ├─ fcos.cpp
      ├─ fcos.hpp
      └─ main.cpp

在 fcos.hpp 中定义了 FCOS 类,在 fcos.cpp 中实现类的成员函数,最后在 main.cpp 测试。首先在类的构造函数中完成相关初始化,包括模型加载、图像加载、计算缩放系数等:

FCOS::FCOS(const char* param, const char* bin,  const char* filename, bool use_gpu)
{
    // 初始化网络
    this->net = new ncnn::Net();

    // 是否使用GPU
    this->net->opt.use_vulkan_compute = use_gpu;

    // 是否使用FP16半精度
    this->net->opt.use_fp16_arithmetic = false;

    // 加载网络结构和权重
    int ret = this->net->load_param(param);
    if (ret != 0)
    {
        printf("[ERROR] Load Param File Failed!\n");
        return;
    }

    ret = this->net->load_model(bin);
    if (ret != 0)
    {
        printf("[ERROR] Load Bin File Failed!\n");
        return;
    }

    // 加载图像
    this->image = cv::imread(filename, 1);
    
    // 图像的宽和高
    this->w = image.cols;
    this->h = image.rows;

    // 计算缩放系数
    this->scale_factor = std::min(this->target_w / float(this->w), this->target_h / float(this->h));
}

模型推理的流程为:
请添加图片描述
① 推理前数据预处理函数:

void FCOS::preprocess(cv::Mat& image, ncnn::Mat& processed_image)
{
    // 缩放后的宽和高
    int resized_w = int(this->w * this->scale_factor);
    int resized_h = int(this->h * this->scale_factor);

    // 将OpenCV的Mat转换为ncnn格式,并resize
    ncnn::Mat in = ncnn::Mat::from_pixels_resize(
        image.data, ncnn::Mat::PIXEL_BGR, this->w, this->h, resized_w, resized_h
    );

    // 宽和高方向上的填充
    int padded_w = (resized_w + this->padding - 1) / this->padding * this->padding - resized_w;
    int padded_h = (resized_h + this->padding - 1) / this->padding * this->padding - resized_h;

    // pad,以左上角为起点开始填充,上面和左边填充0、下面填充padded_h、右边填充padded_w
    ncnn::copy_make_border(in, processed_image, 0, padded_h, 0, padded_w, ncnn::BORDER_CONSTANT, 0.f);

    // 减均值除方差
    processed_image.substract_mean_normalize(this->mean_vals, this->norm_vals);

    // 网络输入宽高
    this->input_w = processed_image.w;
    this->input_h = processed_image.h;
}

② 推理函数:

std::vector<Object> FCOS::detect(ncnn::Mat& input)
{
    // 创建执行器
    ncnn::Extractor ex = this->net->create_extractor();

    // 设置输入
    ex.input("image", input);

    // 存放处理后的框集合
    std::vector<Object> objects;

    // 产生置信度大于指定阈值的框
    for (int i = 0; i < 15; i += 3)
    {
        // 接收输出
        ncnn::Mat cls_pred;
        ncnn::Mat dis_pred;
        ncnn::Mat cnt_pred;

        // 提取三个分支的输出
        ex.extract(this->out_blobs[i].c_str(), cls_pred);
        ex.extract(this->out_blobs[i + 1].c_str(), dis_pred);
        ex.extract(this->out_blobs[i + 2].c_str(), cnt_pred);

        // 解码输出
        std::vector<Object> objs;
        decode(
            cls_pred, dis_pred, cnt_pred, this->stride[i / 3], objs, 
            this->cls_threshold, this->input_w, this->input_h
        );

        // 添加
        objects.insert(objects.end(), objs.begin(), objs.end());
    }

    // 返回
    return objects;
}

③ 推理结果的后处理:

std::vector<Object> FCOS::postprocess(std::vector<Object>& proposals)
{
    // 按照置信度降序排序所有框
    qsort_descent_inplace(proposals);

    // nms
    std::vector<int> picked;
    nms_sorted_bboxes(proposals, picked, this->nms_threshold);

    // 处理保留的框
    int count = picked.size();

    // 存放最终返回结果
    std::vector<Object> objects;
    objects.resize(count);

    // 遍历
    for (int i = 0; i < count; i++)
    {
        // 取对应框
        objects[i] = proposals[picked[i]];

        // 将坐标还原为相对于原图
        float x0 = (objects[i].rect.x) / this->scale_factor;
        float y0 = (objects[i].rect.y) / this->scale_factor;
        float x1 = (objects[i].rect.x + objects[i].rect.width) / this->scale_factor;
        float y1 = (objects[i].rect.y + objects[i].rect.height) / this->scale_factor;

        // 越界处理
        x0 = std::max(std::min(x0, (float)(this->w - 1)), 0.f);
        y0 = std::max(std::min(y0, (float)(this->h - 1)), 0.f);
        x1 = std::max(std::min(x1, (float)(this->w - 1)), 0.f);
        y1 = std::max(std::min(y1, (float)(this->h - 1)), 0.f);

        // 重新放入objects[i]中
        objects[i].rect.x = x0;
        objects[i].rect.y = y0;
        objects[i].rect.width = x1 - x0;
        objects[i].rect.height = y1 - y0;
    }

    // 返回
    return objects;
}

④ 可视化:

void FCOS::visualization(const cv::Mat& img, const std::vector<Object>& objects, const char*)
{
    // 拷贝
    cv::Mat image = img.clone();

    // 遍历所有检测结果
    for (size_t i = 0; i < objects.size(); i++)
    {
        // 取检测结果,并log相关信息
        const Object& obj = objects[i];
        fprintf(stderr, "%d = %.5f at %.2f %.2f %.2f x %.2f\n", obj.label, obj.prob,
                obj.rect.x, obj.rect.y, obj.rect.width, obj.rect.height);

        // 绘制矩形框
        cv::rectangle(image, obj.rect, cv::Scalar(255, 0, 0));

        // log信息
        char text[256];

        // 屏蔽类型不匹配的警告
        #pragma GCC diagnostic ignored "-Wformat="
        sprintf(text, "%s %.1f%%", this->labels[obj.label].c_str(), obj.prob * 100);

        int baseLine = 0;
        cv::Size label_size = cv::getTextSize(text, cv::FONT_HERSHEY_SIMPLEX, 0.5, 1, &baseLine);

        // 绘制文本信息
        int x = obj.rect.x;
        int y = std::max(int(obj.rect.y - label_size.height) - baseLine, 0);

        if (x + label_size.width > image.cols)
            x = image.cols - label_size.width;

        cv::rectangle(image, cv::Rect(cv::Point(x, y), cv::Size(label_size.width, label_size.height + baseLine)),
                      cv::Scalar(255, 255, 255), -1);

        cv::putText(image, text, cv::Point(x, y + label_size.height),
                    cv::FONT_HERSHEY_SIMPLEX, 0.5, cv::Scalar(0, 0, 0));
    }

    // 保存图片
    cv::imwrite("detect.jpg", image);
}

8. 基于安卓平台的推理

在完成 x86 平台的推理后,整体模型推理部分的代码变化不大,Gitee仓库地址。在 AndroidStudio 中新建 native C++ 项目,项目结构为:

app
└─ src
   ├─ AndroidManifest.xml
   ├─ assets
   ├─ cpp
   │    ├─ CMakeLists.txt
   │    ├─ fcos.cpp
   │    ├─ fcos.h
   │    ├─ fcos_jni.cpp
   │    ├─ fcos_jni.h
   │    ├─ include.ncnn
   │    └─ lib
   ├─ java.com.example.ncnn_android_fcos
   │    ├─ Box.java
   │    ├─ FCOS.java
   │    └─ MainActivity.java
   └─ res
      └─ layout

8.1 编译 ncnn

这里以构建 armeabi-v7a 为例,Android ABI 相关内容

cmake -DCMAKE_TOOLCHAIN_FILE="$ANDROID_NDK/build/cmake/android.toolchain.cmake" \
      -DANDROID_ABI="armeabi-v7a" -DANDROID_ARM_NEON=ON \
      -DANDROID_PLATFORM=android-24 -DNCNN_VULKAN=ON ..
make
make install

更多内容,ncnn wiki

8.2 构建安卓工程

首先,整个安卓程序默认在 MainActivity.java 中启动,而 Java 代码无法直接调用 C++ 代码,这里使用 JNI 使二者交互。具体流程为,① 在 FCOS.java 中通过 native 关键字定义待调用的函数,这里将整个推理过程浓缩为 init 和 detect 两个函数:

public static native boolean init(AssetManager manager);
public static native Box[] detect(Bitmap bitmap);

② 在命令行中通过 javac 生成相应的 JNI 头文件 fcos_jni.h(手动改名):

javac -h . -classpath android.jar FCOS.java

③ 新建 fcos_jni.cpp 并完成相应的函数。在 init 函数中完成 FCOS 类的构造函数:

JNIEXPORT jboolean
JNICALL Java_com_example_ncnn_1android_1fcos_FCOS_init
        (JNIEnv * env, jclass, jobject assetManager) {
    if (FCOS::detector == nullptr) {
        AAssetManager* mgr = AAssetManager_fromJava(env, assetManager);
        FCOS::detector = new FCOS(mgr, "fcos_int8.param", "fcos_int8.bin");
    }
    return JNI_TRUE;
}

在 detect 函数中完成推理:

JNIEXPORT jobjectArray
JNICALL Java_com_example_ncnn_1android_1fcos_FCOS_detect
        (JNIEnv * env, jclass, jobject image) {
    // 推理预处理
    ncnn::Mat processed_image;
    FCOS::detector->preprocess(env, image, processed_image);

    // 推理
    std::vector<Object> outs = FCOS::detector->detect(processed_image);

    // 推理后处理
    std::vector<Object> after_outs = FCOS::detector->postprocess(outs);

    // 获取Box类
    jclass box_class = env->FindClass("com/example/ncnn_android_fcos/Box");

    // 获取init函数ID
    jmethodID method_id = env->GetMethodID(box_class, "<init>", "(FFFFIF)V");

    jobjectArray results = env->NewObjectArray(after_outs.size(), box_class, nullptr);

    // 遍历
    int i = 0;
    for (Object& box : after_outs) {
        env->PushLocalFrame(1);
        jobject obj = env->NewObject(box_class, method_id, box.x0, box.y0, box.x1, box.y1,
                box.label, box.score);
        obj = env->PopLocalFrame(obj);
        env->SetObjectArrayElement(results, i++, obj);
    }

    return results;
}

④ 将 x86 的推理代码放到 cpp 目录下,即 fcos.h 和 fcos.cpp 文件。由于 Android 提供了图像处理类 Bitmap,所以这里摈弃了 OpenCV 的使用,代码改动也主要这一点。这样,即完成了在 Java 中调用 C++ 实现的代码。

⑤ 最后,在 MainActivity.java 中通过 FCOS.java 这一中间件即可完成在安卓平台的推理。

8.3 MainActivity.java

在主程序中,首先绑定图片检测和视频检测按钮:

Button detect_image = findViewById(R.id.button_image);
detect_image.setOnClickListener(v -> {
    Intent intent = new Intent(Intent.ACTION_PICK);
    intent.setType("image/*");
    startActivityForResult(intent, REQUEST_IMAGE);
});

Button detect_video = findViewById(R.id.button_video);
detect_video.setOnClickListener(v -> {
    Intent intent = new Intent(Intent.ACTION_PICK);
    intent.setType("video/*");
    startActivityForResult(intent, REQUEST_VIDEO);
});

然后执行 onActivityResult 函数,根据 startActivityForResult 的第二个参数分别调用 detectByImage 和 detectByVideo 分别检测图片和视频。如 detectByImage:

// 获取图像
Bitmap image = getPicture(data.getData());

Thread thread = new Thread(() -> {
    // 拷贝图像
    mutableBitmap = image.copy(Bitmap.Config.ARGB_8888, true);

    // 开始时间
    long start = System.currentTimeMillis();

    // 检测并绘制结果
    mutableBitmap = detectAndDrawResults(mutableBitmap);

    // 计算持续时间
    final long during = System.currentTimeMillis() - start;
    runOnUiThread(() -> {
        // 将绘制好的图像重新贴上去
        imageView.setImageBitmap(mutableBitmap);

        // 图像宽高
        int w = mutableBitmap.getWidth();
        int h = mutableBitmap.getHeight();

        // 展示检测信息
        fpsView.setText(String.format(Locale.CHINESE,
                "Image: %d,%d\nTime: %.3f\nFPS: %.3f",
                h, w, during / 1000.0, 1000.0/during));
    });
});
thread.start();

视频检测思路是逐帧抽取视频的图像分别检测,其他流程同图片检测。这里通过 FFmpegMediaMetadataRetriever 类处理视频,① 计算视频的持续时间,单位是毫秒:

String duration_str = retriever.extractMetadata(
        FFmpegMediaMetadataRetriever.METADATA_KEY_DURATION);
int duration = Integer.parseInt(duration_str);

② 计算视频的 FPS:

String fps_str = retriever.extractMetadata(
                    FFmpegMediaMetadataRetriever.METADATA_KEY_FRAMERATE);
float fps = Float.parseFloat(fps_str);

③ FPS 表示每秒的帧数,则 1.0f / FPS 表示每帧的时长,单位为秒,转换成微秒:

float durr_per_frame = 1.0f / fps * 1000 * 1000 * 1.0f;

④ getFrameAtTime 根据时间节点取对应帧的图像,单位为微秒:

Bitmap image = retriever.getFrameAtTime(video_curr_frame_loc,
                        MediaMetadataRetriever.OPTION_CLOSEST);

⑤ 取到 Bitmap 格式的图像后,后续流程同图片检测。


9. 效果展示

测试模式:真机测试,机型:红米 K40、Android 11

请添加图片描述

由于视频采取逐帧抽取的方式检测,只有模型达到实时检测才能流畅播放。


10. Q&A

【1】编译 ncnn 时出现错误 *** target pattern contains no ‘%’. Stop.

  • 定位到文件,发现是 Protobuf 的错误。将 protoc 添加到环境变量。

参考

【1】https://github.com/PaddlePaddle/PaddleDetection

【2】https://github.com/Tencent/ncnn

【3】https://github.com/RangiLyu/nanodet/tree/main/demo_android_ncnn


  • 1
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值