前言

現在越來越多的手機要使用到深度學習了,比如一些圖像分類,目標檢測,風格遷移等等,之前都是把數據提交給服務器完成的。但是提交給服務器有幾點不好,首先是速度問題,圖片上傳到服務器需要時間,客戶端接收結果也需要時間,這一來回就佔用了一大半的時間,會使得整體的預測速度都變慢了,再且現在手機的性能不斷提高,足以做深度學習的預測。其二是隱私問題,如果只是在本地預測,那麼用戶根本就不用上傳圖片,安全性也大大提高了。所以本章我們就來學如何包我們訓練的PaddlePaddle預測模型部署到Android手機上。

編譯paddle-mobile庫

想要把PaddlePaddle訓練好的預測庫部署到Android手機上,還需要藉助paddle-mobile框架。paddle-mobile框架主要是爲了方便PaddlePaddle訓練好的模型部署到移動設備上,比如Android手機,蘋果手機,樹莓派等等這些移動設備,有了paddle-mobile框架大大方便了把PaddlePaddle的預測庫部署到移動設備上,而且paddle-mobile框架針對移動設備做了大量的優化,使用這些預測庫在移動設備上有了更好的預測性能。

想要在Android手機上使用paddle-mobile,就要編譯Android能夠使用的CPP庫,在這一部分中,我們介紹兩種編譯Android的paddle-mobile庫,分別是使用Docker編譯paddle-mobile庫、使用Ubuntu交叉編譯paddle-mobile庫。

使用Docker編譯

爲了方便操作,以下的操作都是在root用戶的執行的:

1、安裝Docker,以下是在Ubuntu下安裝的的方式,只要一條命令就可以了:

apt-get install docker.io

2、克隆paddle-mobile源碼:

git clone https://github.com/PaddlePaddle/paddle-mobile.git

3、進入到paddle-mobile根目錄下編譯docker鏡像:

cd paddle-mobile
# 編譯生成進行,編譯時間可能要很長
docker build -t paddle-mobile:dev - < Dockerfile

編譯完成可以使用docker images命令查看是否已經生成進行:

root@test:/home/test# docker images
REPOSITORY                          TAG                 IMAGE ID            CREATED             SIZE
paddle-mobile                       dev                 fffbd8779c68        20 hours ago        3.76 GB

4、運行鏡像並進入到容器裏面,當前目錄還是在paddle-mobile根目錄下:

docker run -it -v $PWD:/paddle-mobile paddle-mobile:dev

5、在容器裏面執行以下兩條命令:

root@fc6f7e9ebdf1:/# cd paddle-mobile/
root@fc6f7e9ebdf1:/paddle-mobile# cmake -DCMAKE_TOOLCHAIN_FILE=tools/toolchains/arm-android-neon.cmake

6、(可選)可以使用命令ccmake .配置一些信息,比如可以設置NET僅支持googlenet,這樣便於得到的paddle-mobile庫會更小一些,修改完成之後,使用c命令保存,使用g退出。筆者一般跳過這個步驟。

                                                    Page 1 of 1
 CMAKE_ASM_FLAGS                                                                                                                                                                                
 CMAKE_ASM_FLAGS_DEBUG                                                                                                                                                                          
 CMAKE_ASM_FLAGS_RELEASE                                                                                                                                                                        
 CMAKE_BUILD_TYPE                                                                                                                                                                               
 CMAKE_INSTALL_PREFIX             /usr/local                                                                                                                                                    
 CMAKE_TOOLCHAIN_FILE             /paddle-mobile/tools/toolchains/arm-android-neon.cmake                                                                                                        
 CPU                              ON                                                                                                                                                            
 DEBUGING                         ON                                                                                                                                                            
 FPGA                             OFF                                                                                                                                                           
 LOG_PROFILE                      ON                                                                                                                                                            
 MALI_GPU                         OFF                                                                                                                                                           
 NET                              defult                                                                                                                                                        
 USE_EXCEPTION                    ON                                                                                                                                                            
 USE_OPENMP                       ON                                                       

7、最後執行一下make就可以了,到這一步就完成了paddle-mobile的編譯。

root@fc6f7e9ebdf1:/paddle-mobile# make

8、使用exit命令退出容器,回到Ubuntu本地上。

root@fc6f7e9ebdf1:/paddle-mobile# exit

9、在paddle-mobile根目錄下,有一個build目錄,我們編譯好的paddle-mobile庫就在這裏。

root@test:/home/test/paddle-mobile/build# ls
libpaddle-mobile.so

libpaddle-mobile.so就是我們在開發Android項目的時候使用到的paddle-mobile庫。

使用Ubuntu編譯

1、首先要下載和解壓NDK。

wget https://dl.google.com/android/repository/android-ndk-r17b-linux-x86_64.zip
unzip android-ndk-r17b-linux-x86_64.zip

2、設置NDK環境變量,目錄是NDK的解壓目錄。

export NDK_ROOT="/home/test/android-ndk-r17b"

設置好之後,可以使用以下的命令查看配置情況。

root@test:/home/test# echo $NDK_ROOT
/home/test/android-ndk-r17b

3、安裝cmake,需要安裝較高版本的,筆者的cmake版本是3.11.2。

  • 下載cmake源碼
wget https://cmake.org/files/v3.11/cmake-3.11.2.tar.gz
  • 解壓cmake源碼
tar -zxvf cmake-3.11.2.tar.gz
  • 進入到cmake源碼根目錄,並執行bootstrap
cd cmake-3.11.2
./bootstrap
  • 最後執行以下兩條命令開始安裝cmake。
make
make install
  • 安裝完成之後,可以使用cmake --version是否安裝成功.
root@test:/home/test/paddlepaddle# cmake --version
cmake version 3.11.2

CMake suite maintained and supported by Kitware (kitware.com/cmake).

4、克隆paddle-mobile源碼。

git clone https://github.com/PaddlePaddle/paddle-mobile.git

5、進入到paddle-mobile目錄下,執行編譯。

cd paddle-mobile/
cmake -DCMAKE_TOOLCHAIN_FILE=tools/toolchains/arm-android-neon.cmake
make

6、最後會在paddle-mobile/build目錄下生產paddle-mobile庫。

root@test:/home/test/paddle-mobile/build# ls
libpaddle-mobile.so

libpaddle-mobile.so就是我們在開發Android項目的時候使用到的paddle-mobile庫。

創建Android項目

首先使用Android Studio創建一個普通的Android項目,我們可以不用選擇CPP的支持,因爲我們已經編譯好了CPP。之後按照以下的步驟開始執行:

1、在main目錄下創建兩個assets/infer_model文件夾,這個文件夾我們將會使用它來存放PaddlePaddle訓練好的預測模型,本章我們使用的預測模型是《PaddlePaddle從入門到煉丹》十一——自定義圖像數據集識別訓練得到的預測模型,我們訓練好的模型複製到這個文件夾下。

2、在main目錄下創建一個jniLibs文件夾,這個文件夾是存放CPP編譯庫的,就是編譯paddle-mobile庫部分編譯的libpaddle-mobile.so

3、在Android項目的配置文件夾中加上權限聲明,因爲我們要使用到讀取相冊和使用相機,所以加上以下的權限聲明:

<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />

4、修改activity_main.xml界面,修改成如下:

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <LinearLayout
        android:id="@+id/ll"
        android:orientation="horizontal"
        android:layout_alignParentBottom="true"
        android:layout_width="match_parent"
        android:layout_height="50dp">

        <Button
            android:layout_weight="1"
            android:id="@+id/load"
            android:text="加載模型"
            android:layout_width="0dp"
            android:layout_height="match_parent" />

        <Button
            android:id="@+id/clear"
            android:layout_weight="1"
            android:text="清空模型"
            android:layout_width="0dp"
            android:layout_height="match_parent" />

        <Button
            android:id="@+id/infer"
            android:layout_weight="1"
            android:text="預測圖片"
            android:layout_width="0dp"
            android:layout_height="match_parent" />
    </LinearLayout>

    <TextView
        android:layout_above="@id/ll"
        android:id="@+id/show"
        android:hint="這裏顯示預測結果"
        android:layout_width="match_parent"
        android:layout_height="100dp" />

    <ImageView
        android:id="@+id/image_view"
        android:layout_above="@id/show"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />
</RelativeLayout>

5、創建一個com.baidu.paddle包,在這個包下創建的Java程序,這個Java程序就是用於調用paddle-mobile的CPP動態庫的。它提供了多種方法給我們使用,我們主要使用到加載模型的方法load(String modelDir),清空已加載的方法clear(),還有最最重要的預測方法predictImage(float[] buf, int[]ddims)

package com.baidu.paddle;

public class PML {
    // set thread num
    public static native void setThread(int threadCount);

    //Load seperated parameters
    public static native boolean load(String modelDir);

    // load qualified model
    public static native boolean loadQualified(String modelDir);

    // Load combined parameters
    public static native boolean loadCombined(String modelPath, String paramPath);

    // load qualified model
    public static native boolean loadCombinedQualified(String modelPath, String paramPath);

    // object detection
    public static native float[] predictImage(float[] buf, int[]ddims);

    // predict yuv image
    public static native float[] predictYuv(byte[] buf, int imgWidth, int imgHeight, int[] ddims, float[]meanValues);

    // clear model
    public static native void clear();
}

6、然後在項目的主要包下創建一個Utils.java的工具類。這個工具類主要編寫一些圖像的處理方法,和一些模型複製方法等,我們下面將一一介紹這些方法。

該方法是用於獲取預測結果中概率最大的標籤,參數是執行預測的結果,這個結果是對應沒有類別的概率,這個方法就判斷哪個類別的概率最大,然後就返回概率最大的標籤。

// 獲取預測值中最大概率的標籤
public static int getMaxResult(float[] result) {
    float probability = result[0];
    int r = 0;
    for (int i = 0; i < result.length; i++) {
        if (probability < result[i]) {
            probability = result[i];
            r = i;
        }
    }
    return r;
}

該方法是把圖片轉換成預測需要用的數據格式浮點數組。在轉換的過程中也對圖像做了預處理,這個預處理需要跟訓練的預處理的方式一樣,否則無法正確預測。還有指定了處理後圖片的大小,根據參數輸入的寬度和高度,把圖片壓縮到這些自定的大小。還有把圖片的通道順序改爲RGB,同時每個像素除以255,這個操作跟訓練的時候一樣。

// 對將要預測的圖片進行預處理
public static float[] getScaledMatrix(Bitmap bitmap, int desWidth, int desHeight) {
    float[] dataBuf = new float[3 * desWidth * desHeight];
    int rIndex;
    int gIndex;
    int bIndex;
    int[] pixels = new int[desWidth * desHeight];
    Bitmap bm = Bitmap.createScaledBitmap(bitmap, desWidth, desHeight, false);
    bm.getPixels(pixels, 0, desWidth, 0, 0, desWidth, desHeight);
    int j = 0;
    int k = 0;
    for (int i = 0; i < pixels.length; i++) {
        int clr = pixels[i];
        j = i / desHeight;
        k = i % desWidth;
        rIndex = j * desWidth + k;
        gIndex = rIndex + desHeight * desWidth;
        bIndex = gIndex + desHeight * desWidth;
        // 轉成RGB通道順序,併除以255,跟訓練的預處理一樣
        dataBuf[rIndex] = (float) (((clr & 0x00ff0000) >> 16) / 255.0);
        dataBuf[gIndex] = (float) (((clr & 0x0000ff00) >> 8) / 255.0);
        dataBuf[bIndex] = (float) (((clr & 0x000000ff)) / 255.0);

    }
    if (bm.isRecycled()) {
        bm.recycle();
    }
    return dataBuf;
}

該方法是對圖片進行壓縮,避免圖片過大,超過內存支出。把圖片的最大長度壓縮到500以內。

// 壓縮圖片,避免圖片過大
public static Bitmap getScaleBitmap(String filePath) {
    BitmapFactory.Options opt = new BitmapFactory.Options();
    opt.inJustDecodeBounds = true;
    BitmapFactory.decodeFile(filePath, opt);

    int bmpWidth = opt.outWidth;
    int bmpHeight = opt.outHeight;

    int maxSize = 500;

    // compress picture with inSampleSize
    opt.inSampleSize = 1;
    while (true) {
        if (bmpWidth / opt.inSampleSize < maxSize || bmpHeight / opt.inSampleSize < maxSize) {
            break;
        }
        opt.inSampleSize *= 2;
    }
    opt.inJustDecodeBounds = false;
    return BitmapFactory.decodeFile(filePath, opt);
}

該方法是根據相冊返回的URI轉換爲圖片的絕對路徑,用於之後使用這個路徑獲取圖片內容。

// 根據相冊返回的URI返回圖片的絕對路徑
public static String getPathFromURI(Context context, Uri uri) {
    String result;
    Cursor cursor = context.getContentResolver().query(uri, null, null, null, null);
    if (cursor == null) {
        result = uri.getPath();
    } else {
        cursor.moveToFirst();
        int idx = cursor.getColumnIndex(MediaStore.Images.ImageColumns.DATA);
        result = cursor.getString(idx);
        cursor.close();
    }
    return result;
}

該方法是把assets資源文件下的預測文件複製到緩存目錄,用於之後加載模型文件。

// 複製莫模型文件到緩存目錄
public static void copyFileFromAsset(Context context, String oldPath, String newPath) {
    try {
        // 預測模型文件在assets中的位置
        String[] fileNames = context.getAssets().list(oldPath);
        if (fileNames.length > 0) {
            // directory
            File file = new File(newPath);
            if (!file.exists()) {
                file.mkdirs();
            }
            // copy recursivelyC
            for (String fileName : fileNames) {
                copyFileFromAsset(context, oldPath + "/" + fileName, newPath + "/" + fileName);
            }
        } else {
            // file
            File file = new File(newPath);
            // if file exists will never copy
            if (file.exists()) {
                return;
            }

            // copy file to new path
            InputStream is = context.getAssets().open(oldPath);
            FileOutputStream fos = new FileOutputStream(file);
            byte[] buffer = new byte[1024];
            int byteCount;
            while ((byteCount = is.read(buffer)) != -1) {
                fos.write(buffer, 0, byteCount);
            }
            fos.flush();
            is.close();
            fos.close();
        }
    } catch (Exception e) {
        e.printStackTrace();
    }
}

7、最後修改MainActivity.java,修改如下:

這裏做一些初始化操作,如加載PaddleMobile的動態庫,指定圖片的形狀。

    private String model_path;
    // 模型文件夾
    private String assets_path = "infer_model";
    private boolean load_result = false;
    // 輸入圖片的形狀,分別是:batch size、通道數、寬度、高度
    private int[] ddims = {1, 3, 224, 224};
    private ImageView imageView;
    private TextView showTv;

    // 加載PaddleMobile的動態庫
    static {
        try {
            System.loadLibrary("paddle-mobile");

        } catch (Exception e) {
            e.printStackTrace();

        }
    }

該方法是初始化控件,和定義按鈕的點擊事件,如加載模型點擊事件,清空模型點擊事件,打開相冊預測圖片點擊事件。

    // 初始化控件
    private void initView(){
        Button loadBtn = findViewById(R.id.load);
        Button clearBtn = findViewById(R.id.clear);
        Button inferBtn = findViewById(R.id.infer);
        showTv = findViewById(R.id.show);
        imageView = findViewById(R.id.image_view);

        // 加載模型點擊事件
        loadBtn.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                load_result = PML.load(model_path);
                if (load_result) {
                    Toast.makeText(MainActivity.this, "模型加載成功", Toast.LENGTH_SHORT).show();
                } else {
                    Toast.makeText(MainActivity.this, "模型加載失敗", Toast.LENGTH_SHORT).show();
                }
            }
        });

        // 清空模型點擊事件
        clearBtn.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                PML.clear();
                load_result = false;
                Toast.makeText(MainActivity.this, "模型已清空", Toast.LENGTH_SHORT).show();
            }
        });

        // 打開相冊選擇圖片預測點擊事件
        inferBtn.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                if (load_result){
                    Intent intent = new Intent(Intent.ACTION_PICK);
                    intent.setType("image/*");
                    startActivityForResult(intent, 1);
                } else {
                    Toast.makeText(MainActivity.this, "模型未加載", Toast.LENGTH_SHORT).show();
                }
            }
        });

    }

該方法是一個回調方法,主要是打開相冊後的回調預測操作。使用返回的URI轉換爲絕對路徑,然後使用這個圖片路徑轉換成Bitmap用於顯示,同時也使用這個路徑執行預測操作。

    // 回調事件
    @Override
    protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
        String image_path;
        if (resultCode == Activity.RESULT_OK) {
            switch (requestCode) {
                case 1:
                    if (data == null) {
                        return;
                    }
                    // 獲取相冊返回的URI
                    Uri image_uri = data.getData();
                    // 根據圖片的URI獲取絕對路徑
                    image_path = Utils.getPathFromURI(MainActivity.this, image_uri);
                    // 壓縮圖片用於顯示
                    Bitmap bitmap = Utils.getScaleBitmap(image_path);
                    imageView.setImageBitmap(bitmap);
                    // 開始預測圖片
                    predictImage(image_path);
                    break;
            }
        }
    }

該方法是預測操作的方法,參數是圖片的絕對路徑,首先根據圖片獲取已經壓縮過的Bitmap,然後使用這個Bitmap轉換成預處理後的浮點數組,最後執行預測操作。再根據預測結果提取最大概率的標籤,並獲取該標籤的類別名稱。

    // 根據圖片的路徑預測圖片
    private void predictImage(String image_path) {
        // 把圖片進行壓縮
        Bitmap bmp = Utils.getScaleBitmap(image_path);
        // 把圖片轉換成浮點數組,用於預測
        float[] inputData = Utils.getScaledMatrix(bmp, ddims[2], ddims[3]);
        try {
            long start = System.currentTimeMillis();
            // 執行預測,獲取預測結果
            float[] result = PML.predictImage(inputData, ddims);
            long end = System.currentTimeMillis();
            // 獲取概率最大的標籤
            int r = Utils.getMaxResult(result);
            // 獲取標籤對應的類別名稱
            String[] names = {"蘋果", "哈密瓜", "胡蘿蔔", "櫻桃", "黃瓜", "西瓜"};
            String show_text = "標籤:" + r + "\n名稱:" + names[r] + "\n概率:" + result[r] + "\n時間:" + (end - start) + "ms";
            // 顯示預測結果
            showTv.setText(show_text);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

這主要是用於動態獲取權限,因爲讀取外部文件需要讀取外部文件的權限,又因爲讀取外部文件權限是屬於危險權限,需要動態獲取。

    // 多權限動態申請
    private void requestPermissions() {
        List<String> permissionList = new ArrayList<>();
        if (ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
            permissionList.add(Manifest.permission.WRITE_EXTERNAL_STORAGE);
        }

        if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
            permissionList.add(Manifest.permission.READ_EXTERNAL_STORAGE);
        }

        // if list is not empty will request permissions
        if (!permissionList.isEmpty()) {
            ActivityCompat.requestPermissions(this, permissionList.toArray(new String[permissionList.size()]), 1);
        }
    }

    @Override
    public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults);
        switch (requestCode) {
            case 1:
                if (grantResults.length > 0) {
                    for (int i = 0; i < grantResults.length; i++) {

                        int grantResult = grantResults[i];
                        if (grantResult == PackageManager.PERMISSION_DENIED) {
                            String s = permissions[i];
                            Toast.makeText(this, s + " permission was denied", Toast.LENGTH_SHORT).show();
                        }
                    }
                }
                break;
        }
    }

然後修改onCreate,首先獲取緩存文件路徑,然後初始化視圖控件和動態獲取權限,最後把預測模型文件複製到緩存路徑下。

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        model_path = getCacheDir().getAbsolutePath() + File.separator + "infer_model";
        // 初始化控件
        initView();
        // 動態請求權限
        requestPermissions();
        // 從assets中複製模型文件到緩存目錄下
        Utils.copyFileFromAsset(this, assets_path, model_path);
    }

8、最後運行項目,選擇圖片預測會得到以下的效果:

GitHub地址:https://github.com/yeyupiaoling/LearnPaddle2/tree/master/note15


上一章:《PaddlePaddle從入門到煉丹》十四——把預測模型部署在服務器


參考資料

  1. https://github.com/PaddlePaddle/paddle-mobile
  2. https://blog.csdn.net/qq_33200967/article/details/81066970
小夜