*本篇文章基於 PaddlePaddle 0.11.0、Python 2.7

前言


PaddlePaddle還可以遷移到Android或者Linux設備上,在這些部署了PaddlePaddle的設備同樣可以做深度學習的預測。在這篇文章中我們就介紹如何把PaddlePaddle遷移到Android手機上,並在Android的APP中使用PaddlePaddle。

編譯PaddlePaddle庫


使用Docker編譯PaddlePaddle庫

使用Docker編譯PaddlePaddle真的會很方便,如果你對比了下面部分的使用Linux編譯PaddlePaddle庫,你就會發現使用Docker會少很多麻煩,比如安裝一些依賴庫等等。而且Docker是跨平臺的,不管讀者使用的是Windows,Linux,還是Mac,都可以使用Docker。以下操作方法都是在64位的Ubuntu 16.04上實現的。
首先安裝Docker,在Ubuntu上安裝很簡單,只要一條命令就可以了

sudo apt install docker.io

安裝完成之後,可以使用docker --version命令查看是否安裝成功,如果安裝成功會輸出Docker的版本信息。

然後是在GitHub上克隆PaddlePaddle源碼,命令如下:

git clone https://github.com/PaddlePaddle/Paddle.git

克隆完成PaddlePaddle源碼之後,就可以使用PaddlePaddle源碼創建可以編譯給Android使用的PaddlePaddle庫的Docker容器了:

# 切入到源碼目錄
cd Paddle
# 創建Docker容器
docker build -t mypaddle/paddle-android:dev . -f Dockerfile.android

可能會出現的問題

值得注意的是如果讀者的電腦不能科學上網的,會在下載https://storage.googleapis.com/golang/go1.8.1.linux-amd64.tar.gz的時候報錯,可以修改其下載路徑。使用的文件是在Paddle目錄下的Dockerfile.android,所以要在這個文件中修改,具體在25行,將其修改成https://dl.google.com/go/go1.8.1.linux-amd64.tar.gz,如果可以科學上網,就不必理會。
如果再不行就乾脆去掉GO語言依賴,因爲編譯Android的PaddlePaddle庫根本就不用GO語言依賴庫,具體操作如下:
修改Paddle/CMakeLists.txt下的22行

 project(paddle CXX C Go) 

去掉Go依賴,修改成如下

project(paddle CXX C)

刪除Paddle/Dockerfile.android的Go語言配置

# Install Go and glide
RUN wget -qO- go.tgz https://storage.googleapis.com/golang/go1.8.1.linux-amd64.tar.gz | \
    tar -xz -C /usr/local && \
    mkdir /root/gopath && \
    mkdir /root/gopath/bin && \
    mkdir /root/gopath/src
ENV GOROOT=/usr/local/go GOPATH=/root/gopath
# should not be in the same line with GOROOT definition, otherwise docker build could not find GOROOT.
ENV PATH=${PATH}:${GOROOT}/bin:${GOPATH}/bin

使用官方的Docker容器

如果讀者不想使用源碼創建Docker容器,PaddlePaddle官方也提供了創建好的Docker容器,讀者可以直接拉到本地就可以使用了,命令如下:

docker pull paddlepaddle/paddle:latest-dev-android

以上是國外的鏡像,如果pull的速度慢,可以使用國內的鏡像

docker pull docker.paddlepaddlehub.com/paddle:latest-dev-android

開始編譯PaddlePaddle庫

編譯armeabi-v7aAndroid API 21的PaddlePaddle庫,命令如下,創建PaddlePaddle的配置可以使用e命令設置。在命令的最後可以看到使用的容器是我們自己創建的Docker容器mypaddle/paddle-android:dev,如果換成官方提供的,把Docker名稱修改成paddlepaddle/paddle:latest-dev-android即可。

docker run -it --rm -v $PWD:/paddle -e "ANDROID_ABI=armeabi-v7a" -e "ANDROID_API=21" mypaddle/paddle-android:dev

當編譯完成之後,在$PWD/install_android目錄下創建以下三個目錄,$PWD表示當前目錄,筆者當前目錄爲/home/work/android/docker/。這些文件就是我們之後在Android的APP上會使用的的文件:

  • include是C-API的頭文件
  • lib是Android ABI的PaddlePaddle庫
  • third_party是所依賴的所有第三方庫

上面的是編譯armeabi-v7aAndroid API 21的PaddlePaddle庫,如果讀者想編譯arm64-v8aAndroid API 21的PaddlePaddle庫,只要修改命令參數就可以了,具體命令如下:

docker run -it --rm -v $PWD:/paddle -e "ANDROID_ABI=arm64-v8a" -e "ANDROID_API=21" mypaddle/paddle-android:dev

使用Linux編譯PaddlePaddle庫

如果讀者不習慣與使用Docker,或者想進一步瞭解編譯PaddlePaddle庫的流程,想使用Linux編譯PaddlePaddle庫,這也是沒有問題的,只是步驟比較複雜一些。

安裝依賴環境

首先要安裝編譯的依賴庫,安裝gcc 4.9,命令如下。安裝完成之後使用gcc --version查看安裝是否安裝成功。

sudo apt-get install gcc-4.9

安裝clang 3.8命令如下。同樣安裝完成之後使用clang --version查看安裝是否安裝成功

apt install clang

安裝GO語言環境

apt-get install golang

安裝CMake,最好安裝版本爲3.8以上的。首先下載CMake源碼。

wget https://cmake.org/files/v3.8/cmake-3.8.0.tar.gz

解壓CMake源碼

tar -zxvf cmake-3.8.0.tar.gz

依次執行下面的代碼

# 進入解壓後的目錄
cd cmake-3.8.0
# 執行當前目錄的bootstrap程序
./bootstrap
# make一下,使用12個線程
make -j12
# 開始安裝
sudo make install

配置編譯環境

下載Android NDKAndroid NDK是Android平臺上使用的C/C++交叉編譯工具鏈,Android NDK中包含了所有Android API級別、所有架構(arm/arm64/x86/mips)需要用到的編譯工具和系統庫。下載命令如下:

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

筆者當前的目錄爲/home/work/android/linux/,然後讓它解壓到當前目錄,命令如下:

unzip android-ndk-r14b-linux-x86_64.zip

如果讀者沒有安裝解壓工具,還要先安裝解壓工具unzip,安裝命令如下:

apt install unzip

然後構建armeabi-v7aAndroid API 21的獨立工具鏈,命令如下,使用的腳步是剛下載的Android NDKandroid-ndk-r14b/build/tools/make-standalone-toolchain.sh,生成的獨立工具鏈存放在/home/work/android/linux/arm_standalone_toolchain

/home/work/android/linux/android-ndk-r14b/build/tools/make-standalone-toolchain.sh \
        --arch=arm --platform=android-21 --install-dir=/home/work/android/linux/arm_standalone_toolchain

切入到Paddle目錄下,並創建build目錄

# 切入到Paddle源碼中
cd Paddle
# 創建一個build目錄,在此編譯
mkdir build
# 切入到build目錄
cd build

build目錄下配置交叉編譯參數,編譯的Android ABIarmeabi-v7a,使用的工具鏈是上一面生成的工具鏈/home/work/android/linux/arm_standalone_toolchain,設置存放編譯好的文件存放在/home/work/android/linux/install,具體命令如下,不要少了最後的..,這個是說在上一個目錄使用CMake文件:

cmake -DCMAKE_SYSTEM_NAME=Android \
      -DANDROID_STANDALONE_TOOLCHAIN=/home/work/android/linux/arm_standalone_toolchain \
      -DANDROID_ABI=armeabi-v7a \
      -DANDROID_ARM_NEON=ON \
      -DANDROID_ARM_MODE=ON \
      -DUSE_EIGEN_FOR_BLAS=ON \
      -DCMAKE_INSTALL_PREFIX=/home/work/android/linux/install \
      -DWITH_C_API=ON \
      -DWITH_SWIG_PY=OFF \
      ..

編譯和安裝

CMake配置完成後,執行以下命令,PaddlePaddle將自動下載和編譯所有第三方依賴庫、編譯和安裝PaddlePaddle預測庫。在make前應保證PaddlePaddle的源碼目錄是乾淨的,也就是沒有編譯過其他平臺的PaddlePaddle庫,又或者已經刪除了之前編譯生成的文件。

# 使用12線程make
make -j12
# 開始安裝
make install

當編譯完成之後,在/home/work/android/linux/install目錄下創建以下三個目錄。這些文件就是我們之後在Android的APP上會使用的的文件,這些文件跟我們之前使用Docker編譯的結果是一樣的:

  • include是C-API的頭文件
  • lib是Android ABI的PaddlePaddle庫
  • third_party是所依賴的所有第三方庫

同樣,上面的流程是生成armeabi-v7aAndroid API 21的PaddlePaddle庫。如果要編譯arm64-v8aAndroid API 21的PaddlePaddle庫要修改兩處的參數。
第一處構建獨立工具鏈的時候:

/home/work/android/linux/android-ndk-r14b/build/tools/make-standalone-toolchain.sh \
        --arch=arm64 --platform=android-21 --install-dir=/home/work/android/linux/arm64_standalone_toolchain

第二處是配置交叉編譯參數的時候:

cmake -DCMAKE_SYSTEM_NAME=Android \
      -DANDROID_STANDALONE_TOOLCHAIN=/home/work/android/linux/arm64_standalone_toolchain \
      -DANDROID_ABI=arm64-v8a \
      -DUSE_EIGEN_FOR_BLAS=OFF \
      -DCMAKE_INSTALL_PREFIX=/home/work/android/linux/install \
      -DWITH_C_API=ON \
      -DWITH_SWIG_PY=OFF \
      ..

如果讀者不想操作以上的步驟,也可以直接下載官方編譯好的PaddlePaddle庫,可以在PaddlePaddle開源社區的wiki下載

訓練模型


我們要使用PaddlePadad預先訓練我們的神經網絡模型才能進行下一步操作。我們這次使用的是mobilenet神經網絡,這個網絡更它的名字一樣,是爲了移植到移動設備上的一個神經網絡,雖然我們第三章的CIFAR彩色圖像識別使用的是VGG神經模型,但是使用的流程基本上是一樣的。

定義神經網絡

創建一個mobilenet.py的Python文件,來定義我的mobilenet神經網絡模型。mobilenet是Google針對手機等嵌入式設備提出的一種輕量級的深層神經網絡,它的核心思想就是卷積核的巧妙分解,可以有效減少網絡參數,從而達到減小訓練時網絡的模型。因爲太大的模型參數是不利於移植到移動設備上的,比如我們使用的VGG在訓練CIFAR10的時候,模型會有58M那麼大,這樣的模型如下移植到Android應用上,那會大大增加apk的大小,這樣是不利於應用的推廣的。

# edit-mode: -*- python -*-
import paddle.v2 as paddle


def conv_bn_layer(input,
                  filter_size,
                  num_filters,
                  stride,
                  padding,
                  channels=None,
                  num_groups=1,
                  active_type=paddle.activation.Relu(),
                  layer_type=None):
    """
    A wrapper for conv layer with batch normalization layers.
    Note:
    conv layer has no activation.
    """
    tmp = paddle.layer.img_conv(
        input=input,
        filter_size=filter_size,
        num_channels=channels,
        num_filters=num_filters,
        stride=stride,
        padding=padding,
        groups=num_groups,
        act=paddle.activation.Linear(),
        bias_attr=False,
        layer_type=layer_type)
    return paddle.layer.batch_norm(input=tmp, act=active_type)


def depthwise_separable(input, num_filters1, num_filters2, num_groups, stride,
                        scale):
    """
    """
    tmp = conv_bn_layer(
        input=input,
        filter_size=3,
        num_filters=int(num_filters1 * scale),
        stride=stride,
        padding=1,
        num_groups=int(num_groups * scale),
        layer_type='exconv')

    tmp = conv_bn_layer(
        input=tmp,
        filter_size=1,
        num_filters=int(num_filters2 * scale),
        stride=1,
        padding=0)
    return tmp


def mobile_net(img_size, class_num, scale=1.0):

    img = paddle.layer.data(
        name="image", type=paddle.data_type.dense_vector(img_size))

    # conv1: 112x112
    tmp = conv_bn_layer(
        img,
        filter_size=3,
        channels=3,
        num_filters=int(32 * scale),
        stride=2,
        padding=1)

    # 56x56
    tmp = depthwise_separable(
        tmp,
        num_filters1=32,
        num_filters2=64,
        num_groups=32,
        stride=1,
        scale=scale)
    tmp = depthwise_separable(
        tmp,
        num_filters1=64,
        num_filters2=128,
        num_groups=64,
        stride=2,
        scale=scale)
    # 28x28
    tmp = depthwise_separable(
        tmp,
        num_filters1=128,
        num_filters2=128,
        num_groups=128,
        stride=1,
        scale=scale)
    tmp = depthwise_separable(
        tmp,
        num_filters1=128,
        num_filters2=256,
        num_groups=128,
        stride=2,
        scale=scale)
    # 14x14
    tmp = depthwise_separable(
        tmp,
        num_filters1=256,
        num_filters2=256,
        num_groups=256,
        stride=1,
        scale=scale)
    tmp = depthwise_separable(
        tmp,
        num_filters1=256,
        num_filters2=512,
        num_groups=256,
        stride=2,
        scale=scale)
    # 14x14
    for i in range(5):
        tmp = depthwise_separable(
            tmp,
            num_filters1=512,
            num_filters2=512,
            num_groups=512,
            stride=1,
            scale=scale)
    # 7x7
    tmp = depthwise_separable(
        tmp,
        num_filters1=512,
        num_filters2=1024,
        num_groups=512,
        stride=2,
        scale=scale)
    tmp = depthwise_separable(
        tmp,
        num_filters1=1024,
        num_filters2=1024,
        num_groups=1024,
        stride=1,
        scale=scale)

    tmp = paddle.layer.img_pool(
        input=tmp, pool_size=7, stride=1, pool_type=paddle.pooling.Avg())
    out = paddle.layer.fc(
        input=tmp, size=class_num, act=paddle.activation.Softmax())

    return out


if __name__ == '__main__':
    img_size = 3 * 32 * 32
    data_dim = 10
    out = mobile_net(img_size, data_dim, 1.0)

編寫訓練代碼

然後我們編寫一個trian.py的文件來編寫接下來的Python代碼。

初始化PaddlePaddle

我們創建一個TestCIFAR的類來做我們的訓練,在初始化的時候,我們就讓PaddlePaddle初始化,這裏使用4個GPU來訓練,在PaddlePaddle使用之前,都要初始化PaddlePaddle,但是不能重複初始化。

class TestCIFAR:
    def __init__(self):
        # 初始化paddpaddle,
        paddle.init(use_gpu=True, trainer_count=4)

獲取訓練參數

然後是編寫獲取訓練參數的代碼,這個提供了兩個獲取參數的方法,一個是從損失函數中創建一個訓練參數,另一個是使用之前訓練好的訓練參數:

def get_parameters(self, parameters_path=None, cost=None):
    if not parameters_path:
        # 使用cost創建parameters
        if not cost:
            print "請輸入cost參數"
        else:
            # 根據損失函數創建參數
            parameters = paddle.parameters.create(cost)
            return parameters
    else:
        # 使用之前訓練好的參數
        try:
            # 使用訓練好的參數
            with gzip.open(parameters_path, 'r') as f:
                parameters = paddle.parameters.Parameters.from_tar(f)
            return parameters
        except Exception as e:
            raise NameError("你的參數文件錯誤,具體問題是:%s" % e)

獲取訓練器

通過損失函數、訓練參數、優化方法可以創建一個訓練器。

  • cost,損失函數,通過神經網絡的分類器和分類的標籤可以獲取損失函數。
  • parameters,訓練參數,這個在上已經講過了,這裏就不重複了。
  • optimizer,優化方法,這個優化方法是設置學習率和加正則的。
def get_trainer(self):
    # 數據大小
    datadim = 3 * 32 * 32

    # 獲得圖片對於的信息標籤
    lbl = paddle.layer.data(name="label",
                            type=paddle.data_type.integer_value(10))

    # 獲取全連接層,也就是分類器
    out = mobile_net(datadim, 10, 1.0)

    # 獲得損失函數
    cost = paddle.layer.classification_cost(input=out, label=lbl)

    # 使用之前保存好的參數文件獲得參數
    # parameters = self.get_parameters(parameters_path="../model/mobile_net.tar.gz")
    # 使用損失函數生成參數
    parameters = self.get_parameters(cost=cost)

    '''
    定義優化方法
    learning_rate 迭代的速度
    momentum 跟前面動量優化的比例
    regularzation 正則化,防止過擬合
    '''
    momentum_optimizer = paddle.optimizer.Momentum(
        momentum=0.9,
        regularization=paddle.optimizer.L2Regularization(rate=0.0002 * 128),
        learning_rate=0.1 / 128.0,
        learning_rate_decay_a=0.1,
        learning_rate_decay_b=50000 * 100,
        learning_rate_schedule="discexp")

    '''
    創建訓練器
    cost 分類器
    parameters 訓練參數,可以通過創建,也可以使用之前訓練好的參數
    update_equation 優化方法
    '''
    trainer = paddle.trainer.SGD(cost=cost,
                                 parameters=parameters,
                                 update_equation=momentum_optimizer)
    return trainer

開始訓練

有了訓練器之前,再加上訓練數據就可以進行訓練了,我們還是使用我們比較熟悉的CIFAR10數據集,PaddlePaddle提供了下載接口,只要調用PaddlePaddle的數據接口就可以了。

同時我們也定義了一個訓練事件,通過這個事件可以輸出訓練的日誌,也可以保存我們訓練的參數,比如我們在每一個Pass之後,都會保存訓練參。同時也記錄了訓練和測試的cost和分類錯誤,方便輸出圖像觀察訓練效果。

    def start_trainer(self):
        # 獲得數據
        reader = paddle.batch(reader=paddle.reader.shuffle(reader=paddle.dataset.cifar.train10(),
                                                           buf_size=50000),
                              batch_size=128)

        # 指定每條數據和padd.layer.data的對應關係
        feeding = {"image": 0, "label": 1}

        saveCost = SaveCost()

        lists = []
        # 定義訓練事件,輸出日誌
        def event_handler(event):
            if isinstance(event, paddle.event.EndIteration):
                if event.batch_id % 1 == 0:
                    print "\nPass %d, Batch %d, Cost %f, %s" % (
                        event.pass_id, event.batch_id, event.cost, event.metrics)
                else:
                    sys.stdout.write('.')
                    sys.stdout.flush()

                # 保存訓練的cost,用於生成折線圖,便於觀察
                saveCost.save_trainer_cost(cost=event.cost)
                saveCost.save_trainer_classification_error(error=event.metrics['classification_error_evaluator'])

            # 每一輪訓練完成之後
            if isinstance(event, paddle.event.EndPass):
                # 保存訓練好的參數
                model_path = '../model'
                if not os.path.exists(model_path):
                    os.makedirs(model_path)
                with gzip.open(model_path + '/mobile_net.tar.gz', 'w') as f:
                    trainer.save_parameter_to_tar(f)

                # 測試準確率
                result = trainer.test(reader=paddle.batch(reader=paddle.dataset.cifar.test10(),
                                                          batch_size=128),
                                      feeding=feeding)
                print "\nTest with Pass %d, %s" % (event.pass_id, result.metrics)
                lists.append((event.pass_id, result.cost,
                              result.metrics['classification_error_evaluator']))
                # 保存訓練的cost,用於生成折線圖,便於觀察
                saveCost.save_test_cost(cost=result.cost)
                saveCost.save_test_classification_error(error=result.metrics['classification_error_evaluator'])

        # 獲取訓練器
        trainer = self.get_trainer()

        '''
        開始訓練
        reader 訓練數據
        num_passes 訓練的輪數
        event_handler 訓練的事件,比如在訓練的時候要做一些什麼事情
        feeding 說明每條數據和padd.layer.data的對應關係
        '''
        trainer.train(reader=reader,
                      num_passes=50,
                      event_handler=event_handler,
                      feeding=feeding)

        # find the best pass
        best = sorted(lists, key=lambda list: float(list[1]))[0]
        print 'Best pass is %s, testing Avgcost is %s' % (best[0], best[1])
        print 'The classification accuracy is %.2f%%' % (100 - float(best[2]) * 100)

我們啓動訓練,一共是訓練50個Pass,訓練次數還是比較少的,但是這個模型比較深,訓練數據非常慢,筆者使用4個GPU訓練,大約訓練了39個小時才訓練完成,可以說是非常久的。

if __name__ == '__main__':
    # 開始訓練
    start_train = time.time()
    testCIFAR = TestCIFAR()
    # 開始訓練時間
    testCIFAR.start_trainer()
    # 結束時間
    end_train = time.time()
    print '訓練時間爲:', end_train - start_train, 'ms'

訓練的時候會輸出類似以下的日誌:

Pass 49, Batch 385, Cost 0.172634, {'classification_error_evaluator': 0.046875}
Pass 49, Batch 386, Cost 0.238134, {'classification_error_evaluator': 0.109375}
Pass 49, Batch 387, Cost 0.182165, {'classification_error_evaluator': 0.0546875}
Pass 49, Batch 388, Cost 0.259370, {'classification_error_evaluator': 0.1484375}
Pass 49, Batch 389, Cost 0.221146, {'classification_error_evaluator': 0.0859375}

編寫預測代碼

我們這裏預測不是真正這樣應用,我們使用Python在電腦上測試預測的結果和預測時間,跟之後在Android上的預測做一些對比。

def to_prediction(image_path, parameters, out):
    # 獲取圖片
    def load_image(file):
        im = Image.open(file)
        im = im.resize((32, 32), Image.ANTIALIAS)
        im = np.array(im).astype(np.float32)
        # PIL打開圖片存儲順序爲H(高度),W(寬度),C(通道)。
        # PaddlePaddle要求數據順序爲CHW,所以需要轉換順序。
        im = im.transpose((2, 0, 1))
        # CIFAR訓練圖片通道順序爲B(藍),G(綠),R(紅),
        # 而PIL打開圖片默認通道順序爲RGB,因爲需要交換通道。
        im = im[(2, 1, 0), :, :]  # BGR
        im = im.flatten()
        im = im / 255.0
        return im

    # 獲得要預測的圖片
    test_data = []
    test_data.append((load_image(image_path),))

    # 開始預測時間
    start_infer = time.time()

    # 獲得預測結果
    probs = paddle.infer(output_layer=out,
                         parameters=parameters,
                         input=test_data)
    # 結束預測時間
    end_infer = time.time()

    print '預測時間:', end_infer - start_infer, 'ms'

    # 處理預測結果
    lab = np.argsort(-probs)
    # 返回概率最大的值和其對應的概率值
    return lab[0][0], probs[0][(lab[0][0])]

然後在程序入口處調用預測函數,別忘了在使用PaddlePaddle前要初始化PaddlePaddle,我們這裏使用的是1一個CPU來預測,同時還要從神經網絡中獲取分類器和加載上一步訓練好的模型參數:

if __name__ == '__main__':
    paddle.init(use_gpu=False, trainer_count=2)
    # 開始預測
    out = mobile_net(3 * 32 * 32, 10)
    with gzip.open("../model/mobile_net.tar.gz", 'r') as f:
        parameters = paddle.parameters.Parameters.from_tar(f)
    image_path = "../images/airplane1.png"
    result, probability = to_prediction(image_path=image_path, out=out, parameters=parameters)
    print '預測結果爲:%d,可信度爲:%f' % (result, probability)

預測結果爲,在電腦上預測可以說是相當快的,這裏只是統計預測時間,不包括初始化PaddlePaddle和加載神經網絡的時間:

預測時間: 0.132810115814 ms
預測結果爲:0,可信度爲:0.868770

合併模型


準備文件

合併模型是指把神經網絡和訓練好的模型參數合併生成一個可是直接使用的網絡模型,合併模型需要兩個文件:

  • 模型配置文件: 用於推斷任務的模型配置文件,就是我們用了訓練模型時使用到的神經網絡,必須只包含inference網絡,即不能包含訓練網絡中需要的labelloss以及evaluator層。我們的這裏的模型配置文件就是之前定義的mobilenet.py的mobilenet神經網絡的Python文件。

  • 參數文件: 使用訓練時保存的模型參數,因爲paddle.utils.merge_model合併模型時只讀取.tar.gz,所以保存網絡參數是要注意保存的格式。如果保存的格式爲.tar,也沒有關係,可以把裏面的所有文件提取出來再壓縮爲.tar.gz的文件,壓縮的時候要注意不需要爲這些參數文件創建文件夾,直接壓縮就可以,否則程序會找不到參數文件。保存參數文件程序如下:

with open(model_path + '/model.tar.gz', 'w') as f:
    trainer.save_parameter_to_tar(f)

開始合併

編寫一個Python程序文件merge_model.py來合併模型,代碼如下:

# coding=utf-8
from paddle.utils.merge_model import merge_v2_model

# 導入mobilenet神經網絡
from mobilenet import mobile_net

if __name__ == "__main__":
    # 圖像的大小
    img_size = 3 * 32 * 32
    # 總分類數
    class_dim = 10
    net = mobile_net(img_size, class_dim)
    param_file = '../model/mobile_net.tar.gz'
    output_file = '../model/mobile_net.paddle'
    merge_v2_model(net, param_file, output_file)

成功合併模型後會輸出一下日誌,同時會生成mobile_net.paddle文件。

Generate  ../model/mobile_net.paddle  success!

移植到Android


使用最新的Android Studio創建一個可以支持C++開發的Android項目TestPaddle2

加載PaddlePaddle庫

我們在項目根目錄/app/下創建一個paddle-android文件夾,把第一步編譯好的PaddlePaddle庫的三個文件都存放在這裏,它們分別是:includelibthird_party

把文件存放在paddle-android這裏之後,項目還不能直接使用,還要Android Studio把它們編譯到項目中,我們使用的是項目根目錄/app/CMakeLists.txt,我們介紹一下它都加載了哪些庫:

  • set(CMAKE_MODULE_PATH ${CMAKE_MODULE_PATH} "${CMAKE_CURRENT_SOURCE_DIR}/"):設置.cmake文件查找的路徑
  • set(PADDLE_ROOT ${CMAKE_SOURCE_DIR}/paddle-android):設置paddle-android庫的路徑,在項目根目錄/app/FindPaddle.cmake裏面需要用到,,該文件加載PaddlePaddle庫的,因爲該文件代碼比較多,筆者就不展示了,可以自行查看源碼。
  • find_package(Paddle):查找paddle-android庫的頭文件和庫文件是否存在
    set(SRC_FILES src/main/cpp/image_recognizer.cpp):項目中所有C++源碼文件
  • add_library(paddle_image_recognizer SHARED ${SRC_FILES}):生成動態庫即.so文件

加載完成PaddlePaddle之後,我們還有加載一個文件,那就是我們第二步合併的模型,這個模型是我們要用來預測圖像的,所以接下來我們就看看如何處理我們的合併的模型。

加載合併模型

我們把合併的模型mobile_net.paddle存放在項目根目錄/app/src/main/assets/model.include,然後通過調用PaddlePaddle的接口就可以加載完成合並模型,把路徑model/include/mobile_net.paddle傳入即可,還是聽方便的。

long size;
void* buf = BinaryReader()(merged_model_path, &size);

ECK(paddle_gradient_machine_create_for_inference_with_parameters(
      &gradient_machine_, buf, size));

爲什麼我們可以直接這樣傳路徑,而不用帶前面的路徑呢,這是因爲我們在app下的build.gradle做了一些設置,在android增加了這幾行代碼:

sourceSets {
        main {
            manifest.srcFile "src/main/AndroidManifest.xml"
            java.srcDirs = ["src/main/java"]
            assets.srcDirs = ["src/main/assets"]
            jni.srcDirs = ["src/main/cpp"]
            jniLibs.srcDirs = ["paddle-android/lib"]
        }
    }

這樣只要在傳路徑之前,把上下文傳給BinaryReader即可:

AAssetManager *aasset_manager = AAssetManager_fromJava(env, jasset_manager);
BinaryReader::set_aasset_manager(aasset_manager);

這個部分在這裏不細講,到下一部分筆者再把這個流程再講一下。

開發Android程序

加載完成PaddlePaddle庫之後,就可以使用PaddlePaddle來做我們的Android開發了,接下來我們就開始開發Android應用吧。

這裏對於Android的開發筆者不會細講,因爲這裏主要是講在Android應用PaddlePaddle,所以筆者只會講一些關鍵的代碼。

在應用啓動是,我們就應該讓它初始化和加載模型:

初始化PaddlePaddle

這個跟我們在Python上的初始化是差不多的,在初始化是指定是否使用GPU,通過paddle_initCAPI接口初始化PaddlePaddle:

JNIEXPORT void
Java_com_yeyupiaoling_testpaddle_ImageRecognition_initPaddle(JNIEnv *env, jobject thiz) {
    static bool called = false;
    if (!called) {
        // Initalize Paddle
        char* argv[] = {const_cast<char*>("--use_gpu=False"),
                        const_cast<char*>("--pool_limit_size=0")};
        CHECK(paddle_init(2, (char**)argv));
        called = true;
    }
}

這個C++的函數對應的是Java中ImageRecognition類的方法

// CPP中初始化PaddlePaddle
public native void initPaddle();

這個Java類主要是用來給MainActivity.java調用C++函數的,同ImageRecognitionnative方法,其他的Java類就可以調用自己寫的C++函數了,但是不要忘了,要在ImageRecognition這個列中加載我們編寫的C++程序:

static {
      System.loadLibrary("image_recognition");
}

加載合併模型

因爲我們使用的是合併模型,所以跟之前在Python上使用的有點不一樣,在Python的時候,我們要使用到升級網絡輸出的分類器out和訓練是保存的模型參數parameters。而在這裏,我們使用到的是合併模型,這個合併模型已經包含了分類器和模型參數了,所以只要這一個文件就可以了。

JNIEXPORT void
Java_com_yeyupiaoling_testpaddle_ImageRecognition_loadModel(JNIEnv *env,
                                                            jobject thiz,
                                                            jobject jasset_manager,
                                                            jstring modelPath) {
    //加載上下文
    AAssetManager *aasset_manager = AAssetManager_fromJava(env, jasset_manager);
    BinaryReader::set_aasset_manager(aasset_manager);

    const char *merged_model_path = env->GetStringUTFChars(modelPath, 0);
    // Step 1: Reading merged model.
    LOGI("merged_model_path = %s", merged_model_path);
    long size;
    void *buf = BinaryReader()(merged_model_path, &size);
    // Create a gradient machine for inference.
    CHECK(paddle_gradient_machine_create_for_inference_with_parameters(
            &gradient_machine_, buf, size));
    // 釋放空間
    env->ReleaseStringUTFChars(modelPath, merged_model_path);
    LOGI("加載模型成功");
    free(buf);
    buf = nullptr;
}

而這個方法就對應ImageRecognition類的方法:

// CPP中加載預測合併模型
public native void loadModel(AssetManager assetManager, String modelPath);

這一步和上面的初始化PaddlePaddle都是要在activity加的時候就應該執行了:

imageRecognition = new ImageRecognition();
imageRecognition.initPaddle();
imageRecognition.loadModel(this.getAssets(), "model/include/mobile_net.paddle");

預測圖像

這個是我們的預測CPP程序,這個調用了PaddlePaddle的CAPI,通過這些接口來讓模型做一個向前的計算,通過這個計算來獲取到我們的預測結果。
因爲PaddlePaddle讀取的數據是float數組,而我們傳過來的只是字節數組,所以我們要對數據進行轉換,加了一個把字節數的jpixels的轉成float數組的array。最後我們獲得的結果也是一個float數組的array,這個是每個類別對於的概率:

JNIEXPORT jfloatArray
Java_com_yeyupiaoling_testpaddle_ImageRecognition_infer(JNIEnv *env,
                                                        jobject thiz,
                                                        jbyteArray jpixels) {

    //網絡的輸入和輸出被組織爲paddle_arguments對象
    //在C-API中。在下面的評論中,“argument”具體指的是一個輸入
    //PaddlePaddle C-API中的神經網絡。
    paddle_arguments in_args = paddle_arguments_create_none();

    //調用函數來創建一個參數。
    CHECK(paddle_arguments_resize(in_args, 1));

    //每個參數需要一個矩陣或一個ivector(整數向量,稀疏
    //索引輸入,通常用於NLP任務)來保存真實的輸入數據。
    //在下面的評論中,“matrix”具體指的是需要的對象
    //參數來保存數據。這裏我們爲上面創建的矩陣創建
    //儲存測試樣品的存量。
    paddle_matrix mat = paddle_matrix_create(1, 3072, false);

    paddle_real *array;
    //獲取指向第一行開始地址的指針
    //創建矩陣。
    CHECK(paddle_matrix_get_row(mat, 0, &array));

    //獲取字節數組轉換成浮點數組
    unsigned char *pixels =
            (unsigned char *) env->GetByteArrayElements(jpixels, 0);
    // RGB/RGBA -> RGB
    size_t index = 0;
    std::vector<float> means;
    means.clear();
    for (size_t i = 0; i < 3; ++i) {
        means.push_back(0.0f);
    }
    for (size_t c = 0; c < 3; ++c) {
        for (size_t h = 0; h < 32; ++h) {
            for (size_t w = 0; w < 32; ++w) {
                array[index] =
                        static_cast<float>(
                                pixels[(h * 32 + w) * 3 + c]) - means[c];
                index++;
            }
        }
    }
    env->ReleaseByteArrayElements(jpixels, (jbyte *) pixels, 0);

    //將矩陣分配給輸入參數。
    CHECK(paddle_arguments_set_value(in_args, 0, mat));

    //創建輸出參數。
    paddle_arguments out_args = paddle_arguments_create_none();

    //調用向前計算。
    CHECK(paddle_gradient_machine_forward(gradient_machine_, in_args, out_args, false));

    //創建矩陣來保存神經網絡的向前結果。
    paddle_matrix prob = paddle_matrix_create_none();
    //訪問輸出參數的矩陣,預測結果存儲在哪個。
    CHECK(paddle_arguments_get_value(out_args, 0, prob));

    uint64_t height;
    uint64_t width;
    //獲取矩陣的大小
    CHECK(paddle_matrix_get_shape(prob, &height, &width));
    //獲取預測結果矩陣
    CHECK(paddle_matrix_get_row(prob, 0, &array));

    jfloatArray result = env->NewFloatArray(height * width);
    env->SetFloatArrayRegion(result, 0, height * width, array);

    // 清空內存
    CHECK(paddle_matrix_destroy(prob));
    CHECK(paddle_arguments_destroy(out_args));
    CHECK(paddle_matrix_destroy(mat));
    CHECK(paddle_arguments_destroy(in_args));

    return result;
}

這個方法對應ImageRecognition類的方法:

// CPP中獲取預測結果
private native float[] infer(byte[] pixels);

在Java中,我們要獲取到圖像數據,我們從相冊中獲取圖像:

//打開相冊
private void getPhoto() {
    Intent intent = new Intent(Intent.ACTION_PICK);
    intent.setType("image/*");
    startActivityForResult(intent, 1);
}

如果讀者的手機是Android 6.0以上的,我們還有做一個動態獲取權限的操作:

//從相冊獲取照片
getPhotoBtn.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View v) {
        if (ContextCompat.checkSelfPermission(MainActivity.this,
                Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
            ActivityCompat.requestPermissions(MainActivity.this,
                    new String[]{Manifest.permission.READ_EXTERNAL_STORAGE}, 1);
        } else {
            getPhoto();
        }
    }
});

然後要在權限回調中也要做相應的操作,比如申請權限成功之後要打開相冊,申請權限失敗要提示用戶打開相冊失敗:

// 動態申請權限回調
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions,
                                       @NonNull int[] grantResults) {
    switch (requestCode) {
        case 1:
            if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
                getPhoto();
            } else {
                toastUtil.showToast("你拒絕了授權");
            }
            break;
    }
}

最後當用戶選擇圖像只是,在回調中可以獲取該圖像的URI:

// 相冊獲取照片回調
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
    if (resultCode == Activity.RESULT_OK) {
        switch (requestCode) {
            case 1:
                Uri uri = data.getData();
                break;
        }
    }
}

然後編寫一個工具類來把URI轉成圖像的路徑:

//獲取圖片的路徑
public static String getRealPathFromURI(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;
}

之後通過調用這個方法就可以獲取到圖像的路徑了:

String imagePath = CameraUtil.getRealPathFromURI(MainActivity.this, uri);

最後在調用預測方法,獲取到預測結果:

String resutl = imageRecognition.infer(imagePath);

這裏要注意,這個的infer方法不是我們的真正調用C++函數的方法,我們C++的預測函數傳入的是一個字節數組:

private native float[] infer(byte[] pixels);

所以我們要把獲得的圖像轉換成字節數組,再去調用預測的C++接口:

public String infer(String img_path) {
    //把圖像讀取成一個Bitmap對象
    Bitmap bitmap = BitmapFactory.decodeFile(img_path);
    Bitmap mBitmap = bitmap.copy(Bitmap.Config.ARGB_8888, true);
    mBitmap.setWidth(32);
    mBitmap.setHeight(32);
    int width = mBitmap.getWidth();
    int height = mBitmap.getHeight();
    int channel = 3;
    //把圖像生成一個數組
    byte[] pixels = getPixelsBGR(mBitmap);
    // 獲取預測結果
    float[] result = infer(pixels, width, height, channel);
    // 把概率最大的結果提取出來
    float max = 0;
    int number = 0;
    for (int i = 0; i < result.length; i++) {
        if (result[i] > max) {
            max = result[i];
            number = i;
        }
    }
    String msg = "類別爲:" + clasName[number] + ",可信度爲:" + max;
    Log.i("ImageRecognition", msg);
    return msg;
}

其中我們調用了一個getPixelsBGR()方法,這個CIFAR圖片在訓練時的通道順序爲B(藍)、G(綠)、R(紅),而我們使用Bitmap讀取圖像的通道是RGB順序的,所以我們還有轉換一下它們的通道順序,轉換方法如下:

public byte[] getPixelsBGR(Bitmap bitmap) {
    // 計算我們的圖像包含多少字節
    int bytes = bitmap.getByteCount();

    ByteBuffer buffer = ByteBuffer.allocate(bytes);
    // 將字節數據移動到緩衝區
    bitmap.copyPixelsToBuffer(buffer);

    // 獲取包含數據的基礎數組
    byte[] temp = buffer.array();

    byte[] pixels = new byte[(temp.length/4) * 3];
    // 進行像素複製
    for (int i = 0; i < temp.length/4; i++) {
        pixels[i * 3] = temp[i * 4 + 2]; //B
        pixels[i * 3 + 1] = temp[i * 4 + 1]; //G
        pixels[i * 3 + 2] = temp[i * 4 ]; //R
    }
    return pixels;
}

這個我們的預測結果的截圖:


上一章:《我的PaddlePaddle學習之路》筆記十三——把PaddlePaddle部署到網站服務器上


項目代碼


GitHub地址:https://github.com/yeyupiaoling/LearnPaddle

參考資料


  1. http://paddlepaddle.org/
  2. https://github.com/PaddlePaddle/Mobile/tree/develop/Demo/Android/AICamera
  3. http://blog.csdn.net/wfei101/article/details/78310226
  4. https://arxiv.org/abs/1704.04861
小夜