diff --git a/.clang_format.hook b/.clang_format.hook new file mode 100644 index 0000000000000000000000000000000000000000..1d928216867c0ba3897d71542fea44debf8d72a0 --- /dev/null +++ b/.clang_format.hook @@ -0,0 +1,15 @@ +#!/bin/bash +set -e + +readonly VERSION="3.8" + +version=$(clang-format -version) + +if ! [[ $version == *"$VERSION"* ]]; then + echo "clang-format version check failed." + echo "a version contains '$VERSION' is needed, but get '$version'" + echo "you can install the right version, and make an soft-link to '\$PATH' env" + exit -1 +fi + +clang-format $@ diff --git a/.gitignore b/.gitignore index f936b570d1b928eaa8e2ce136b9ce276f3f73876..7d03b965587fa90f38740ddf44c6602e95aea73d 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,5 @@ output/ *.idea *.log +.clang-format +.clang_format.hook diff --git a/README.md b/README.md index 3585c474e0064c931127db3decf096dff374aaa1..dfeef4a128806096b833771d9a696cb9daa597e8 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ PaddleOCR旨在打造一套丰富、领先、且实用的OCR工具库,助力 - 超轻量级中文OCR在线体验地址:https://www.paddlepaddle.org.cn/hub/scene/ocr -- [**中文OCR模型快速使用**](./doc/doc_ch/quickstart.md) +- [**中文OCR模型快速使用**](./doc/doc_ch/quickstart.md) ## 中文OCR模型列表 @@ -50,7 +50,7 @@ PaddleOCR旨在打造一套丰富、领先、且实用的OCR工具库,助力 - [基于Python预测引擎推理](./doc/doc_ch/inference.md) - 基于C++预测引擎推理(comming soon) - [服务部署](./doc/doc_ch/serving.md) - - 端侧部署(comming soon) + - [端侧部署](./deploy/lite/readme.md) - [数据集](./doc/doc_ch/datasets.md) - [FAQ](#FAQ) - 效果展示 diff --git a/deploy/imgs/demo.png b/deploy/imgs/demo.png new file mode 100644 index 0000000000000000000000000000000000000000..761bfb9baa505cf3450d0702151555bba4196ec5 Binary files /dev/null and b/deploy/imgs/demo.png differ diff --git a/deploy/lite/Makefile b/deploy/lite/Makefile new file mode 100644 index 0000000000000000000000000000000000000000..96e05ecf01904fdcb21a103e78783da6dd748ca9 --- /dev/null +++ b/deploy/lite/Makefile @@ -0,0 +1,77 @@ +ARM_ABI = arm8 +export ARM_ABI + +include ../Makefile.def + +LITE_ROOT=../../../ + +THIRD_PARTY_DIR=${LITE_ROOT}/third_party + +OPENCV_VERSION=opencv4.1.0 + +OPENCV_LIBS = ../../../third_party/${OPENCV_VERSION}/arm64-v8a/libs/libopencv_imgcodecs.a \ + ../../../third_party/${OPENCV_VERSION}/arm64-v8a/libs/libopencv_imgproc.a \ + ../../../third_party/${OPENCV_VERSION}/arm64-v8a/libs/libopencv_core.a \ + ../../../third_party/${OPENCV_VERSION}/arm64-v8a/3rdparty/libs/libtegra_hal.a \ + ../../../third_party/${OPENCV_VERSION}/arm64-v8a/3rdparty/libs/liblibjpeg-turbo.a \ + ../../../third_party/${OPENCV_VERSION}/arm64-v8a/3rdparty/libs/liblibwebp.a \ + ../../../third_party/${OPENCV_VERSION}/arm64-v8a/3rdparty/libs/liblibpng.a \ + ../../../third_party/${OPENCV_VERSION}/arm64-v8a/3rdparty/libs/liblibjasper.a \ + ../../../third_party/${OPENCV_VERSION}/arm64-v8a/3rdparty/libs/liblibtiff.a \ + ../../../third_party/${OPENCV_VERSION}/arm64-v8a/3rdparty/libs/libIlmImf.a \ + ../../../third_party/${OPENCV_VERSION}/arm64-v8a/3rdparty/libs/libtbb.a \ + ../../../third_party/${OPENCV_VERSION}/arm64-v8a/3rdparty/libs/libcpufeatures.a + +OPENCV_INCLUDE = -I../../../third_party/${OPENCV_VERSION}/arm64-v8a/include + +CXX_INCLUDES = $(INCLUDES) ${OPENCV_INCLUDE} -I$(LITE_ROOT)/cxx/include + +CXX_LIBS = ${OPENCV_LIBS} -L$(LITE_ROOT)/cxx/lib/ -lpaddle_light_api_shared $(SYSTEM_LIBS) + +############################################################### +# How to use one of static libaray: # +# `libpaddle_api_full_bundled.a` # +# `libpaddle_api_light_bundled.a` # +############################################################### +# Note: default use lite's shared library. # +############################################################### +# 1. Comment above line using `libpaddle_light_api_shared.so` +# 2. Undo comment below line using `libpaddle_api_light_bundled.a` + +#CXX_LIBS = $(LITE_ROOT)/cxx/lib/libpaddle_api_light_bundled.a $(SYSTEM_LIBS) + +ocr_db_crnn: fetch_opencv ocr_db_crnn.o crnn_process.o db_post_process.o clipper.o + $(CC) $(SYSROOT_LINK) $(CXXFLAGS_LINK) ocr_db_crnn.o crnn_process.o db_post_process.o clipper.o -o ocr_db_crnn $(CXX_LIBS) $(LDFLAGS) + +ocr_db_crnn.o: ocr_db_crnn.cc + $(CC) $(SYSROOT_COMPLILE) $(CXX_DEFINES) $(CXX_INCLUDES) $(CXX_FLAGS) -o ocr_db_crnn.o -c ocr_db_crnn.cc + +crnn_process.o: fetch_opencv crnn_process.cc + $(CC) $(SYSROOT_COMPLILE) $(CXX_DEFINES) $(CXX_INCLUDES) $(CXX_FLAGS) -o crnn_process.o -c crnn_process.cc + +db_post_process.o: fetch_clipper fetch_opencv db_post_process.cc + $(CC) $(SYSROOT_COMPLILE) $(CXX_DEFINES) $(CXX_INCLUDES) $(CXX_FLAGS) -o db_post_process.o -c db_post_process.cc + +clipper.o: fetch_clipper + $(CC) $(SYSROOT_COMPLILE) $(CXX_DEFINES) $(CXX_INCLUDES) $(CXX_FLAGS) -o clipper.o -c clipper.cpp + +fetch_clipper: + @test -e clipper.hpp || \ + ( echo "Fetch clipper " && \ + wget -c https://paddle-inference-dist.cdn.bcebos.com/PaddleLite/Clipper/clipper.hpp) + @ test -e clipper.cpp || \ + wget -c https://paddle-inference-dist.cdn.bcebos.com/PaddleLite/Clipper/clipper.cpp + +fetch_opencv: + @ test -d ${THIRD_PARTY_DIR} || mkdir ${THIRD_PARTY_DIR} + @ test -e ${THIRD_PARTY_DIR}/${OPENCV_VERSION}.tar.gz || \ + (echo "fetch opencv libs" && \ + wget -P ${THIRD_PARTY_DIR} https://paddle-inference-dist.bj.bcebos.com/${OPENCV_VERSION}.tar.gz) + @ test -d ${THIRD_PARTY_DIR}/${OPENCV_VERSION} || \ + tar -zxvf ${THIRD_PARTY_DIR}/${OPENCV_VERSION}.tar.gz -C ${THIRD_PARTY_DIR} + + +.PHONY: clean +clean: + rm -f ocr_db_crnn.o clipper.o db_post_process.o crnn_process.o + rm -f ocr_db_crnn diff --git a/deploy/lite/config.txt b/deploy/lite/config.txt new file mode 100644 index 0000000000000000000000000000000000000000..8ed835dd2c055b2cf1abb31c3c380204d66d7f2a --- /dev/null +++ b/deploy/lite/config.txt @@ -0,0 +1,4 @@ +max_side_len 960 +det_db_thresh 0.3 +det_db_box_thresh 0.5 +det_db_unclip_ratio 2.0 \ No newline at end of file diff --git a/deploy/lite/crnn_process.cc b/deploy/lite/crnn_process.cc new file mode 100644 index 0000000000000000000000000000000000000000..9f3df37d2745c67a219a60af99882ff06573d9f4 --- /dev/null +++ b/deploy/lite/crnn_process.cc @@ -0,0 +1,111 @@ +// Copyright (c) 2020 PaddlePaddle Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "crnn_process.h" //NOLINT +#include +#include +#include + +const std::vector rec_image_shape{3, 32, 320}; + +cv::Mat CrnnResizeImg(cv::Mat img, float wh_ratio) { + int imgC, imgH, imgW; + imgC = rec_image_shape[0]; + imgW = rec_image_shape[2]; + imgH = rec_image_shape[1]; + + imgW = int(32 * wh_ratio); + + float ratio = float(img.cols) / float(img.rows); + int resize_w, resize_h; + if (ceilf(imgH * ratio) > imgW) + resize_w = imgW; + else + resize_w = int(ceilf(imgH * ratio)); + cv::Mat resize_img; + cv::resize(img, resize_img, cv::Size(resize_w, imgH), 0.f, 0.f, + cv::INTER_LINEAR); + + return resize_img; +} + +std::vector ReadDict(std::string path) { + std::ifstream in(path); + std::string filename; + std::string line; + std::vector m_vec; + if (in) { + while (getline(in, line)) { + m_vec.push_back(line); + } + } else { + std::cout << "no such file" << std::endl; + } + return m_vec; +} + +cv::Mat GetRotateCropImage(cv::Mat srcimage, + std::vector> box) { + cv::Mat image; + srcimage.copyTo(image); + std::vector> points = box; + + int x_collect[4] = {box[0][0], box[1][0], box[2][0], box[3][0]}; + int y_collect[4] = {box[0][1], box[1][1], box[2][1], box[3][1]}; + int left = int(*std::min_element(x_collect, x_collect + 4)); + int right = int(*std::max_element(x_collect, x_collect + 4)); + int top = int(*std::min_element(y_collect, y_collect + 4)); + int bottom = int(*std::max_element(y_collect, y_collect + 4)); + + cv::Mat img_crop; + image(cv::Rect(left, top, right - left, bottom - top)).copyTo(img_crop); + + for (int i = 0; i < points.size(); i++) { + points[i][0] -= left; + points[i][1] -= top; + } + + int img_crop_width = int(sqrt(pow(points[0][0] - points[1][0], 2) + + pow(points[0][1] - points[1][1], 2))); + int img_crop_height = int(sqrt(pow(points[0][0] - points[3][0], 2) + + pow(points[0][1] - points[3][1], 2))); + + cv::Point2f pts_std[4]; + pts_std[0] = cv::Point2f(0., 0.); + pts_std[1] = cv::Point2f(img_crop_width, 0.); + pts_std[2] = cv::Point2f(img_crop_width, img_crop_height); + pts_std[3] = cv::Point2f(0.f, img_crop_height); + + cv::Point2f pointsf[4]; + pointsf[0] = cv::Point2f(points[0][0], points[0][1]); + pointsf[1] = cv::Point2f(points[1][0], points[1][1]); + pointsf[2] = cv::Point2f(points[2][0], points[2][1]); + pointsf[3] = cv::Point2f(points[3][0], points[3][1]); + + cv::Mat M = cv::getPerspectiveTransform(pointsf, pts_std); + + cv::Mat dst_img; + cv::warpPerspective(img_crop, dst_img, M, + cv::Size(img_crop_width, img_crop_height), + cv::BORDER_REPLICATE); + + if (float(dst_img.rows) >= float(dst_img.cols) * 1.5) { + cv::Mat srcCopy = cv::Mat(dst_img.rows, dst_img.cols, dst_img.depth()); + cv::transpose(dst_img, srcCopy); + cv::flip(srcCopy, srcCopy, 0); + return srcCopy; + } else { + return dst_img; + } +} diff --git a/deploy/lite/crnn_process.h b/deploy/lite/crnn_process.h new file mode 100644 index 0000000000000000000000000000000000000000..29e67906976198210394c4960786105bf884dce8 --- /dev/null +++ b/deploy/lite/crnn_process.h @@ -0,0 +1,38 @@ +// Copyright (c) 2020 PaddlePaddle Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "math.h" //NOLINT +#include "opencv2/core.hpp" +#include "opencv2/imgcodecs.hpp" +#include "opencv2/imgproc.hpp" + +cv::Mat CrnnResizeImg(cv::Mat img, float wh_ratio); + +std::vector ReadDict(std::string path); + +cv::Mat GetRotateCropImage(cv::Mat srcimage, std::vector> box); + +template +inline size_t Argmax(ForwardIterator first, ForwardIterator last) { + return std::distance(first, std::max_element(first, last)); +} diff --git a/deploy/lite/db_post_process.cc b/deploy/lite/db_post_process.cc new file mode 100644 index 0000000000000000000000000000000000000000..a6cffe7b8ebfc2489c01b8970da30b8d54300f22 --- /dev/null +++ b/deploy/lite/db_post_process.cc @@ -0,0 +1,279 @@ +// Copyright (c) 2020 PaddlePaddle Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "db_post_process.h" // NOLINT +#include +#include + +void GetContourArea(std::vector> box, float unclip_ratio, + float &distance) { + int pts_num = 4; + float area = 0.0f; + float dist = 0.0f; + for (int i = 0; i < pts_num; i++) { + area += box[i][0] * box[(i + 1) % pts_num][1] - + box[i][1] * box[(i + 1) % pts_num][0]; + dist += sqrtf((box[i][0] - box[(i + 1) % pts_num][0]) * + (box[i][0] - box[(i + 1) % pts_num][0]) + + (box[i][1] - box[(i + 1) % pts_num][1]) * + (box[i][1] - box[(i + 1) % pts_num][1])); + } + area = fabs(float(area / 2.0)); + + distance = area * unclip_ratio / dist; +} + +cv::RotatedRect Unclip(std::vector> box, + float unclip_ratio) { + float distance = 1.0; + + GetContourArea(box, unclip_ratio, distance); + + ClipperLib::ClipperOffset offset; + ClipperLib::Path p; + p << ClipperLib::IntPoint(int(box[0][0]), int(box[0][1])) + << ClipperLib::IntPoint(int(box[1][0]), int(box[1][1])) + << ClipperLib::IntPoint(int(box[2][0]), int(box[2][1])) + << ClipperLib::IntPoint(int(box[3][0]), int(box[3][1])); + offset.AddPath(p, ClipperLib::jtRound, ClipperLib::etClosedPolygon); + + ClipperLib::Paths soln; + offset.Execute(soln, distance); + std::vector points; + + for (int j = 0; j < soln.size(); j++) { + for (int i = 0; i < soln[soln.size() - 1].size(); i++) { + points.emplace_back(soln[j][i].X, soln[j][i].Y); + } + } + cv::RotatedRect res = cv::minAreaRect(points); + + return res; +} + +std::vector> Mat2Vector(cv::Mat mat) { + std::vector> img_vec; + std::vector tmp; + + for (int i = 0; i < mat.rows; ++i) { + tmp.clear(); + for (int j = 0; j < mat.cols; ++j) { + tmp.push_back(mat.at(i, j)); + } + img_vec.push_back(tmp); + } + return img_vec; +} + +bool XsortFp32(std::vector a, std::vector b) { + if (a[0] != b[0]) + return a[0] < b[0]; + return false; +} + +bool XsortInt(std::vector a, std::vector b) { + if (a[0] != b[0]) + return a[0] < b[0]; + return false; +} + +std::vector> +OrderPointsClockwise(std::vector> pts) { + std::vector> box = pts; + std::sort(box.begin(), box.end(), XsortInt); + + std::vector> leftmost = {box[0], box[1]}; + std::vector> rightmost = {box[2], box[3]}; + + if (leftmost[0][1] > leftmost[1][1]) + std::swap(leftmost[0], leftmost[1]); + + if (rightmost[0][1] > rightmost[1][1]) + std::swap(rightmost[0], rightmost[1]); + + std::vector> rect = {leftmost[0], rightmost[0], rightmost[1], + leftmost[1]}; + return rect; +} + +std::vector> GetMiniBoxes(cv::RotatedRect box, float &ssid) { + ssid = std::max(box.size.width, box.size.height); + + cv::Mat points; + cv::boxPoints(box, points); + + auto array = Mat2Vector(points); + std::sort(array.begin(), array.end(), XsortFp32); + + std::vector idx1 = array[0], idx2 = array[1], idx3 = array[2], + idx4 = array[3]; + if (array[3][1] <= array[2][1]) { + idx2 = array[3]; + idx3 = array[2]; + } else { + idx2 = array[2]; + idx3 = array[3]; + } + if (array[1][1] <= array[0][1]) { + idx1 = array[1]; + idx4 = array[0]; + } else { + idx1 = array[0]; + idx4 = array[1]; + } + + array[0] = idx1; + array[1] = idx2; + array[2] = idx3; + array[3] = idx4; + + return array; +} + +float BoxScoreFast(std::vector> box_array, cv::Mat pred) { + auto array = box_array; + int width = pred.cols; + int height = pred.rows; + + float box_x[4] = {array[0][0], array[1][0], array[2][0], array[3][0]}; + float box_y[4] = {array[0][1], array[1][1], array[2][1], array[3][1]}; + + int xmin = clamp(int(std::floorf(*(std::min_element(box_x, box_x + 4)))), 0, + width - 1); + int xmax = clamp(int(std::ceilf(*(std::max_element(box_x, box_x + 4)))), 0, + width - 1); + int ymin = clamp(int(std::floorf(*(std::min_element(box_y, box_y + 4)))), 0, + height - 1); + int ymax = clamp(int(std::ceilf(*(std::max_element(box_y, box_y + 4)))), 0, + height - 1); + + cv::Mat mask; + mask = cv::Mat::zeros(ymax - ymin + 1, xmax - xmin + 1, CV_8UC1); + + cv::Point root_point[4]; + root_point[0] = cv::Point(int(array[0][0]) - xmin, int(array[0][1]) - ymin); + root_point[1] = cv::Point(int(array[1][0]) - xmin, int(array[1][1]) - ymin); + root_point[2] = cv::Point(int(array[2][0]) - xmin, int(array[2][1]) - ymin); + root_point[3] = cv::Point(int(array[3][0]) - xmin, int(array[3][1]) - ymin); + const cv::Point *ppt[1] = {root_point}; + int npt[] = {4}; + cv::fillPoly(mask, ppt, npt, 1, cv::Scalar(1)); + + cv::Mat croppedImg; + pred(cv::Rect(xmin, ymin, xmax - xmin + 1, ymax - ymin + 1)) + .copyTo(croppedImg); + + auto score = cv::mean(croppedImg, mask)[0]; + return score; +} + +std::vector>> +BoxesFromBitmap(const cv::Mat pred, const cv::Mat bitmap, + std::map Config) { + const int min_size = 3; + const int max_candidates = 1000; + const float box_thresh = float(Config["det_db_box_thresh"]); + const float unclip_ratio = float(Config["det_db_unclip_ratio"]); + + int width = bitmap.cols; + int height = bitmap.rows; + + std::vector> contours; + std::vector hierarchy; + + cv::findContours(bitmap, contours, hierarchy, cv::RETR_LIST, + cv::CHAIN_APPROX_SIMPLE); + + int num_contours = + contours.size() >= max_candidates ? max_candidates : contours.size(); + + std::vector>> boxes; + + for (int i = 0; i < num_contours; i++) { + float ssid; + cv::RotatedRect box = cv::minAreaRect(contours[i]); + auto array = GetMiniBoxes(box, ssid); + + auto box_for_unclip = array; + // end get_mini_box + + if (ssid < min_size) { + continue; + } + + float score; + score = BoxScoreFast(array, pred); + // end box_score_fast + if (score < box_thresh) + continue; + + // start for unclip + cv::RotatedRect points = Unclip(box_for_unclip, unclip_ratio); + // end for unclip + + cv::RotatedRect clipbox = points; + auto cliparray = GetMiniBoxes(clipbox, ssid); + + if (ssid < min_size + 2) + continue; + + int dest_width = pred.cols; + int dest_height = pred.rows; + std::vector> intcliparray; + + for (int num_pt = 0; num_pt < 4; num_pt++) { + std::vector a{int(clamp(roundf(cliparray[num_pt][0] / float(width) * + float(dest_width)), + float(0), float(dest_width))), + int(clamp(roundf(cliparray[num_pt][1] / float(height) * + float(dest_height)), + float(0), float(dest_height)))}; + intcliparray.push_back(a); + } + boxes.push_back(intcliparray); + + } // end for + return boxes; +} + +std::vector>> +FilterTagDetRes(std::vector>> boxes, float ratio_h, + float ratio_w, cv::Mat srcimg) { + int oriimg_h = srcimg.rows; + int oriimg_w = srcimg.cols; + + std::vector>> root_points; + for (int n = 0; n < boxes.size(); n++) { + boxes[n] = OrderPointsClockwise(boxes[n]); + for (int m = 0; m < boxes[0].size(); m++) { + boxes[n][m][0] /= ratio_w; + boxes[n][m][1] /= ratio_h; + + boxes[n][m][0] = int(std::min(std::max(boxes[n][m][0], 0), oriimg_w - 1)); + boxes[n][m][1] = int(std::min(std::max(boxes[n][m][1], 0), oriimg_h - 1)); + } + } + + for (int n = 0; n < boxes.size(); n++) { + int rect_width, rect_height; + rect_width = int(sqrt(pow(boxes[n][0][0] - boxes[n][1][0], 2) + + pow(boxes[n][0][1] - boxes[n][1][1], 2))); + rect_height = int(sqrt(pow(boxes[n][0][0] - boxes[n][3][0], 2) + + pow(boxes[n][0][1] - boxes[n][3][1], 2))); + if (rect_width <= 10 || rect_height <= 10) + continue; + root_points.push_back(boxes[n]); + } + return root_points; +} diff --git a/deploy/lite/db_post_process.h b/deploy/lite/db_post_process.h new file mode 100644 index 0000000000000000000000000000000000000000..06dbcb2c462404062b6d8f0e23ccb795273f4320 --- /dev/null +++ b/deploy/lite/db_post_process.h @@ -0,0 +1,62 @@ +// Copyright (c) 2020 PaddlePaddle Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#pragma once + +#include + +#include +#include +#include + +#include "clipper.hpp" +#include "opencv2/core.hpp" +#include "opencv2/imgcodecs.hpp" +#include "opencv2/imgproc.hpp" + +template T clamp(T x, T min, T max) { + if (x > max) + return max; + if (x < min) + return min; + return x; +} + +std::vector> Mat2Vector(cv::Mat mat); + +void GetContourArea(std::vector> box, float unclip_ratio, + float &distance); + +cv::RotatedRect Unclip(std::vector> box, float unclip_ratio); + +std::vector> Mat2Vector(cv::Mat mat); + +bool XsortFp32(std::vector a, std::vector b); + +bool XsortInt(std::vector a, std::vector b); + +std::vector> +OrderPointsClockwise(std::vector> pts); + +std::vector> GetMiniBoxes(cv::RotatedRect box, float &ssid); + +float BoxScoreFast(std::vector> box_array, cv::Mat pred); + +std::vector>> +BoxesFromBitmap(const cv::Mat pred, const cv::Mat bitmap, + std::map Config); + +std::vector>> +FilterTagDetRes(std::vector>> boxes, float ratio_h, + float ratio_w, cv::Mat srcimg); diff --git a/deploy/lite/ocr_db_crnn.cc b/deploy/lite/ocr_db_crnn.cc new file mode 100644 index 0000000000000000000000000000000000000000..d251df3fb4d600186e0378fc47602b1ad98e6073 --- /dev/null +++ b/deploy/lite/ocr_db_crnn.cc @@ -0,0 +1,364 @@ +// Copyright (c) 2020 PaddlePaddle Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "paddle_api.h" // NOLINT +#include + +#include "crnn_process.h" +#include "db_post_process.h" + +using namespace paddle::lite_api; // NOLINT +using namespace std; + +// fill tensor with mean and scale and trans layout: nhwc -> nchw, neon speed up +void neon_mean_scale(const float *din, float *dout, int size, + const std::vector mean, + const std::vector scale) { + if (mean.size() != 3 || scale.size() != 3) { + std::cerr << "[ERROR] mean or scale size must equal to 3\n"; + exit(1); + } + float32x4_t vmean0 = vdupq_n_f32(mean[0]); + float32x4_t vmean1 = vdupq_n_f32(mean[1]); + float32x4_t vmean2 = vdupq_n_f32(mean[2]); + float32x4_t vscale0 = vdupq_n_f32(scale[0]); + float32x4_t vscale1 = vdupq_n_f32(scale[1]); + float32x4_t vscale2 = vdupq_n_f32(scale[2]); + + float *dout_c0 = dout; + float *dout_c1 = dout + size; + float *dout_c2 = dout + size * 2; + + int i = 0; + for (; i < size - 3; i += 4) { + float32x4x3_t vin3 = vld3q_f32(din); + float32x4_t vsub0 = vsubq_f32(vin3.val[0], vmean0); + float32x4_t vsub1 = vsubq_f32(vin3.val[1], vmean1); + float32x4_t vsub2 = vsubq_f32(vin3.val[2], vmean2); + float32x4_t vs0 = vmulq_f32(vsub0, vscale0); + float32x4_t vs1 = vmulq_f32(vsub1, vscale1); + float32x4_t vs2 = vmulq_f32(vsub2, vscale2); + vst1q_f32(dout_c0, vs0); + vst1q_f32(dout_c1, vs1); + vst1q_f32(dout_c2, vs2); + + din += 12; + dout_c0 += 4; + dout_c1 += 4; + dout_c2 += 4; + } + for (; i < size; i++) { + *(dout_c0++) = (*(din++) - mean[0]) * scale[0]; + *(dout_c1++) = (*(din++) - mean[1]) * scale[1]; + *(dout_c2++) = (*(din++) - mean[2]) * scale[2]; + } +} + +// resize image to a size multiple of 32 which is required by the network +cv::Mat DetResizeImg(const cv::Mat img, int max_size_len, + std::vector &ratio_hw) { + int w = img.cols; + int h = img.rows; + + float ratio = 1.f; + int max_wh = w >= h ? w : h; + if (max_wh > max_size_len) { + if (h > w) { + ratio = float(max_size_len) / float(h); + } else { + ratio = float(max_size_len) / float(w); + } + } + + int resize_h = int(float(h) * ratio); + int resize_w = int(float(w) * ratio); + if (resize_h % 32 == 0) + resize_h = resize_h; + else if (resize_h / 32 < 1 + 1e-5) + resize_h = 32; + else + resize_h = (resize_h / 32 - 1) * 32; + + if (resize_w % 32 == 0) + resize_w = resize_w; + else if (resize_w / 32 < 1 + 1e-5) + resize_w = 32; + else + resize_w = (resize_w / 32 - 1) * 32; + + cv::Mat resize_img; + cv::resize(img, resize_img, cv::Size(resize_w, resize_h)); + + ratio_hw.push_back(float(resize_h) / float(h)); + ratio_hw.push_back(float(resize_w) / float(w)); + return resize_img; +} + +void RunRecModel(std::vector>> boxes, cv::Mat img, + std::shared_ptr predictor_crnn, + std::vector &rec_text, + std::vector &rec_text_score, + std::vector charactor_dict) { + std::vector mean = {0.5f, 0.5f, 0.5f}; + std::vector scale = {1 / 0.5f, 1 / 0.5f, 1 / 0.5f}; + + cv::Mat srcimg; + img.copyTo(srcimg); + cv::Mat crop_img; + cv::Mat resize_img; + + int index = 0; + for (int i = boxes.size() - 1; i >= 0; i--) { + crop_img = GetRotateCropImage(srcimg, boxes[i]); + float wh_ratio = float(crop_img.cols) / float(crop_img.rows); + + resize_img = CrnnResizeImg(crop_img, wh_ratio); + resize_img.convertTo(resize_img, CV_32FC3, 1 / 255.f); + + const float *dimg = reinterpret_cast(resize_img.data); + + std::unique_ptr input_tensor0( + std::move(predictor_crnn->GetInput(0))); + input_tensor0->Resize({1, 3, resize_img.rows, resize_img.cols}); + auto *data0 = input_tensor0->mutable_data(); + + neon_mean_scale(dimg, data0, resize_img.rows * resize_img.cols, mean, + scale); + //// Run CRNN predictor + predictor_crnn->Run(); + + // Get output and run postprocess + std::unique_ptr output_tensor0( + std::move(predictor_crnn->GetOutput(0))); + auto *rec_idx = output_tensor0->data(); + + auto rec_idx_lod = output_tensor0->lod(); + auto shape_out = output_tensor0->shape(); + + std::vector pred_idx; + for (int n = int(rec_idx_lod[0][0]); n < int(rec_idx_lod[0][1]); n += 1) { + pred_idx.push_back(int(rec_idx[n])); + } + + if (pred_idx.size() < 1e-3) + continue; + + index += 1; + std::string pred_txt = ""; + for (int n = 0; n < pred_idx.size(); n++) { + pred_txt += charactor_dict[pred_idx[n]]; + } + rec_text.push_back(pred_txt); + + ////get score + std::unique_ptr output_tensor1( + std::move(predictor_crnn->GetOutput(1))); + auto *predict_batch = output_tensor1->data(); + auto predict_shape = output_tensor1->shape(); + + auto predict_lod = output_tensor1->lod(); + + int argmax_idx; + int blank = predict_shape[1]; + float score = 0.f; + int count = 0; + float max_value = 0.0f; + + for (int n = predict_lod[0][0]; n < predict_lod[0][1] - 1; n++) { + argmax_idx = int(Argmax(&predict_batch[n * predict_shape[1]], + &predict_batch[(n + 1) * predict_shape[1]])); + max_value = + float(*std::max_element(&predict_batch[n * predict_shape[1]], + &predict_batch[(n + 1) * predict_shape[1]])); + + if (blank - 1 - argmax_idx > 1e-5) { + score += max_value; + count += 1; + } + } + score /= count; + rec_text_score.push_back(score); + } +} + +std::vector>> +RunDetModel(std::shared_ptr predictor, cv::Mat img, + std::map Config) { + // Read img + int max_side_len = int(Config["max_side_len"]); + + cv::Mat srcimg; + img.copyTo(srcimg); + + std::vector ratio_hw; + img = DetResizeImg(img, max_side_len, ratio_hw); + cv::Mat img_fp; + img.convertTo(img_fp, CV_32FC3, 1.0 / 255.f); + + // Prepare input data from image + std::unique_ptr input_tensor0(std::move(predictor->GetInput(0))); + input_tensor0->Resize({1, 3, img_fp.rows, img_fp.cols}); + auto *data0 = input_tensor0->mutable_data(); + + std::vector mean = {0.485f, 0.456f, 0.406f}; + std::vector scale = {1 / 0.229f, 1 / 0.224f, 1 / 0.225f}; + const float *dimg = reinterpret_cast(img_fp.data); + neon_mean_scale(dimg, data0, img_fp.rows * img_fp.cols, mean, scale); + + // Run predictor + predictor->Run(); + + // Get output and post process + std::unique_ptr output_tensor( + std::move(predictor->GetOutput(0))); + auto *outptr = output_tensor->data(); + auto shape_out = output_tensor->shape(); + + // Save output + float pred[shape_out[2] * shape_out[3]]; + unsigned char cbuf[shape_out[2] * shape_out[3]]; + + for (int i = 0; i < int(shape_out[2] * shape_out[3]); i++) { + pred[i] = float(outptr[i]); + cbuf[i] = (unsigned char)((outptr[i]) * 255); + } + + cv::Mat cbuf_map(shape_out[2], shape_out[3], CV_8UC1, (unsigned char *)cbuf); + cv::Mat pred_map(shape_out[2], shape_out[3], CV_32F, (float *)pred); + + const double threshold = double(Config["det_db_thresh"]) * 255; + const double maxvalue = 255; + cv::Mat bit_map; + cv::threshold(cbuf_map, bit_map, threshold, maxvalue, cv::THRESH_BINARY); + + auto boxes = BoxesFromBitmap(pred_map, bit_map, Config); + + std::vector>> filter_boxes = + FilterTagDetRes(boxes, ratio_hw[0], ratio_hw[1], srcimg); + + return filter_boxes; +} + +std::shared_ptr loadModel(std::string model_file) { + MobileConfig config; + config.set_model_from_file(model_file); + + std::shared_ptr predictor = + CreatePaddlePredictor(config); + return predictor; +} + +cv::Mat Visualization(cv::Mat srcimg, + std::vector>> boxes) { + cv::Point rook_points[boxes.size()][4]; + for (int n = 0; n < boxes.size(); n++) { + for (int m = 0; m < boxes[0].size(); m++) { + rook_points[n][m] = cv::Point(int(boxes[n][m][0]), int(boxes[n][m][1])); + } + } + cv::Mat img_vis; + srcimg.copyTo(img_vis); + for (int n = 0; n < boxes.size(); n++) { + const cv::Point *ppt[1] = {rook_points[n]}; + int npt[] = {4}; + cv::polylines(img_vis, ppt, npt, 1, 1, CV_RGB(0, 255, 0), 2, 8, 0); + } + + cv::imwrite("./vis.jpg", img_vis); + std::cout << "The detection visualized image saved in ./vis.jpg" << std::endl; + return img_vis; +} + +std::vector split(const std::string &str, + const std::string &delim) { + std::vector res; + if ("" == str) + return res; + char *strs = new char[str.length() + 1]; + std::strcpy(strs, str.c_str()); + + char *d = new char[delim.length() + 1]; + std::strcpy(d, delim.c_str()); + + char *p = std::strtok(strs, d); + while (p) { + string s = p; + res.push_back(s); + p = std::strtok(NULL, d); + } + + return res; +} + +std::map LoadConfigTxt(std::string config_path) { + auto config = ReadDict(config_path); + + std::map dict; + for (int i = 0; i < config.size(); i++) { + std::vector res = split(config[i], " "); + dict[res[0]] = stod(res[1]); + } + return dict; +} + +int main(int argc, char **argv) { + if (argc < 5) { + std::cerr << "[ERROR] usage: " << argv[0] + << " det_model_file rec_model_file image_path\n"; + exit(1); + } + std::string det_model_file = argv[1]; + std::string rec_model_file = argv[2]; + std::string img_path = argv[3]; + std::string dict_path = argv[4]; + + //// load config from txt file + auto Config = LoadConfigTxt("./config.txt"); + + auto start = std::chrono::system_clock::now(); + + auto det_predictor = loadModel(det_model_file); + auto rec_predictor = loadModel(rec_model_file); + + auto charactor_dict = ReadDict(dict_path); + + cv::Mat srcimg = cv::imread(img_path, cv::IMREAD_COLOR); + auto boxes = RunDetModel(det_predictor, srcimg, Config); + + std::vector rec_text; + std::vector rec_text_score; + RunRecModel(boxes, srcimg, rec_predictor, rec_text, rec_text_score, + charactor_dict); + + auto end = std::chrono::system_clock::now(); + auto duration = + std::chrono::duration_cast(end - start); + + //// visualization + auto img_vis = Visualization(srcimg, boxes); + + //// print recognized text + for (int i = 0; i < rec_text.size(); i++) { + std::cout << i << "\t" << rec_text[i] << "\t" << rec_text_score[i] + << std::endl; + } + + std::cout << "花费了" + << double(duration.count()) * + std::chrono::microseconds::period::num / + std::chrono::microseconds::period::den + << "秒" << std::endl; + + return 0; +} diff --git a/deploy/lite/readme.md b/deploy/lite/readme.md new file mode 100644 index 0000000000000000000000000000000000000000..107841dc0bbf5058e5c95453ecaf0f7dc1b73d80 --- /dev/null +++ b/deploy/lite/readme.md @@ -0,0 +1,214 @@ +# PaddleOCR 端侧模型部署 + +本教程将介绍在移动端部署PaddleOCR超轻量中文检测、识别模型的详细步骤。 + + +## 1. 准备环境 + +### 运行准备 +- 电脑(编译Paddle-Lite) +- 安卓手机(armv7或armv8) + +### 1.1 准备交叉编译环境 +交叉编译环境用于编译[Paddle-Lite](https://github.com/PaddlePaddle/Paddle-Lite)和PaddleOCR的C++ demo。 +支持多种开发环境,不同开发环境的编译流程请参考对应文档。 +1. [Docker](https://paddle-lite.readthedocs.io/zh/latest/user_guides/source_compile.html#docker) +2. [Linux](https://paddle-lite.readthedocs.io/zh/latest/user_guides/source_compile.html#android) +3. [MAC OS](https://paddle-lite.readthedocs.io/zh/latest/user_guides/source_compile.html#id13) +4. [Windows](https://paddle-lite.readthedocs.io/zh/latest/demo_guides/x86.html#windows) + +### 1.2 准备预测库 + +预测库有两种获取方式: +- 1. 直接下载,下载[链接](https://paddle-lite.readthedocs.io/zh/latest/user_guides/release_lib.html#android-toolchain-gcc). + 注意选择`with_extra=ON,with_cv=ON`的下载链接。 +- 2. 编译Paddle-Lite得到,Paddle-Lite的编译方式如下: +``` +git clone https://github.com/PaddlePaddle/Paddle-Lite.git +cd Paddle-Lite +git checkout 2.6.1 +./lite/tools/build_android.sh --arch=armv8 --with_cv=ON --with_extra=ON +``` + +注意:编译Paddle-Lite获得预测库时,需要打开`--with_cv=ON --with_extra=ON`两个选项,`--arch`表示`arm`版本,这里指定为armv8, +更多编译命令 +介绍请参考[链接](https://paddle-lite.readthedocs.io/zh/latest/user_guides/Compile/Android.html#id2)。 + +直接下载预测库并解压后,可以得到`inference_lite_lib.android.armv8/`文件夹,通过编译Paddle-Lite得到的预测库位于 +`Paddle-Lite/build.lite.android.armv8.gcc/inference_lite_lib.android.armv8/`文件夹下。 +预测库的文件目录如下: +``` +inference_lite_lib.android.armv8/ +|-- cxx C++ 预测库和头文件 +| |-- include C++ 头文件 +| | |-- paddle_api.h +| | |-- paddle_image_preprocess.h +| | |-- paddle_lite_factory_helper.h +| | |-- paddle_place.h +| | |-- paddle_use_kernels.h +| | |-- paddle_use_ops.h +| | `-- paddle_use_passes.h +| `-- lib C++预测库 +| |-- libpaddle_api_light_bundled.a C++静态库 +| `-- libpaddle_light_api_shared.so C++动态库 +|-- java Java预测库 +| |-- jar +| | `-- PaddlePredictor.jar +| |-- so +| | `-- libpaddle_lite_jni.so +| `-- src +|-- demo C++和Java示例代码 +| |-- cxx C++ 预测库demo +| `-- java Java 预测库demo +``` + +## 2 开始运行 + +### 2.1 模型优化 + +Paddle-Lite 提供了多种策略来自动优化原始的模型,其中包括量化、子图融合、混合调度、Kernel优选等方法,使用Paddle-lite的opt工具可以自动 +对inference模型进行优化,优化后的模型更轻量,模型运行速度更快。 + +下述表格中提供了优化好的超轻量中文模型: + +|模型简介|检测模型|识别模型|Paddle-Lite版本| +|-|-|-|-| +|超轻量级中文OCR opt优化模型|[下载地址](https://paddleocr.bj.bcebos.com/deploy/lite/ch_det_mv3_db_opt.nb)|[下载地址](https://paddleocr.bj.bcebos.com/deploy/lite/ch_rec_mv3_crnn_opt.nb)|2.6.1| + +如果直接使用上述表格中的模型进行部署,可略过下述步骤,直接阅读 [2.2节](###2.2与手机联调)。 + +如果要部署的模型不在上述表格中,则需要按照如下步骤获得优化后的模型。 + +模型优化需要Paddle-Lite的opt可执行文件,可以通过编译Paddle-Lite源码获得,编译步骤如下: +``` +# 如果准备环境时已经clone了Paddle-Lite,则不用重新clone Paddle-Lite +git clone https://github.com/PaddlePaddle/Paddle-Lite.git +cd Paddle-Lite +git checkout 2.6.1 +# 启动编译 +./lite/tools/build.sh build_optimize_tool +``` + +编译完成后,opt文件位于`build.opt/lite/api/`下,可通过如下方式查看opt的运行选项和使用方式; +``` +cd build.opt/lite/api/ +./opt +``` + +|选项|说明| +|-|-| +|--model_dir|待优化的PaddlePaddle模型(非combined形式)的路径| +|--model_file|待优化的PaddlePaddle模型(combined形式)的网络结构文件路径| +|--param_file|待优化的PaddlePaddle模型(combined形式)的权重文件路径| +|--optimize_out_type|输出模型类型,目前支持两种类型:protobuf和naive_buffer,其中naive_buffer是一种更轻量级的序列化/反序列化实现。若您需要在mobile端执行模型预测,请将此选项设置为naive_buffer。默认为protobuf| +|--optimize_out|优化模型的输出路径| +|--valid_targets|指定模型可执行的backend,默认为arm。目前可支持x86、arm、opencl、npu、xpu,可以同时指定多个backend(以空格分隔),Model Optimize Tool将会自动选择最佳方式。如果需要支持华为NPU(Kirin 810/990 Soc搭载的达芬奇架构NPU),应当设置为npu, arm| +|--record_tailoring_info|当使用 根据模型裁剪库文件 功能时,则设置该选项为true,以记录优化后模型含有的kernel和OP信息,默认为false| + +`--model_dir`适用于待优化的模型是非combined方式,PaddleOCR的inference模型是combined方式,即模型结构和模型参数使用单独一个文件存储。 + +下面以PaddleOCR的超轻量中文模型为例,介绍使用编译好的opt文件完成inference模型到Paddle-Lite优化模型的转换。 + +``` +# 下载PaddleOCR的超轻量文inference模型,并解压 +wget https://paddleocr.bj.bcebos.com/ch_models/ch_det_mv3_db_infer.tar && tar xf ch_det_mv3_db_infer.tar +wget https://paddleocr.bj.bcebos.com/ch_models/ch_rec_mv3_crnn_infer.tar && tar xf ch_rec_mv3_crnn_infer.tar + +# 转换检测模型 +./opt --model_file=./ch_det_mv3_db/model --param_file=./ch_det_mv3_db/params --optimize_out_type=naive_buffer --optimize_out=./ch_det_mv3_db_opt --valid_targets=arm + +# 转换识别模型 +./opt --model_file=./ch_rec_mv3_crnn/model --param_file=./ch_rec_mv3_crnn/params --optimize_out_type=naive_buffer --optimize_out=./ch_rec_mv3_crnn_opt --valid_targets=arm +``` + +转换成功后,当前目录下会多出`ch_det_mv3_db_opt.nb`, `ch_rec_mv3_crnn_opt.nb`结尾的文件,即是转换成功的模型文件。 + +注意:使用paddle-lite部署时,需要使用opt工具优化后的模型。 opt 转换的输入模型是paddle保存的inference模型 + +### 2.2 与手机联调 + +首先需要进行一些准备工作。 + 1. 准备一台arm8的安卓手机,如果编译的预测库和opt文件是armv7,则需要arm7的手机,并修改Makefile中`ARM_ABI = arm7`。 + 2. 打开手机的USB调试选项,选择文件传输模式,连接电脑。 + 3. 电脑上安装adb工具,用于调试。 adb安装方式如下: + + 3.1. MAC电脑安装ADB: + ``` + brew cask install android-platform-tools + ``` + 3.2. Linux安装ADB + ``` + sudo apt update + sudo apt install -y wget adb + ``` + 3.3. Window安装ADB + + win上安装需要去谷歌的安卓平台下载adb软件包进行安装:[链接](https://developer.android.com/studio) + + 打开终端,手机连接电脑,在终端中输入 + ``` + adb devices + ``` + 如果有device输出,则表示安装成功。 + ``` + List of devices attached + 744be294 device + ``` + + 4. 准备优化后的模型、预测库文件、测试图像和使用的字典文件。 + 在预测库`inference_lite_lib.android.armv8/demo/cxx/`下新建一个`ocr/`文件夹, + 将PaddleOCR repo中`PaddleOCR/deploy/lite/` 下的除`readme.md`所有文件放在新建的ocr文件夹下。在`ocr`文件夹下新建一个`debug`文件夹, + 将C++预测库so文件复制到debug文件夹下。 + ``` + # 进入OCR demo的工作目录 + cd demo/cxx/ocr/ + # 将C++预测动态库so文件复制到debug文件夹中 + cp ../../../../cxx/lib/libpaddle_light_api_shared.so ./debug/ + ``` + 准备测试图像,以`PaddleOCR/doc/imgs/11.jpg`为例,将测试的图像复制到`demo/cxx/ocr/debug/`文件夹下。 + 准备字典文件,中文超轻量模型的字典文件是`PaddleOCR/ppocr/utils/ppocr_keys_v1.txt`,将其复制到`demo/cxx/ocr/debug/`文件夹下。 + + 执行完成后,ocr文件夹下将有如下文件格式: + +``` +demo/cxx/ocr/ +|-- debug/ +| |--ch_det_mv3_db_opt.nb 优化后的检测模型文件 +| |--ch_rec_mv3_crnn_opt.nb 优化后的识别模型文件 +| |--11.jpg 待测试图像 +| |--ppocr_keys_v1.txt 字典文件 +| |--libpaddle_light_api_shared.so C++预测库文件 +| |--config.txt DB-CRNN超参数配置 +|-- config.txt DB-CRNN超参数配置 +|-- crnn_process.cc 识别模型CRNN的预处理和后处理文件 +|-- crnn_process.h +|-- db_post_process.cc 检测模型DB的后处理文件 +|-- db_post_process.h +|-- Makefile 编译文件 +|-- ocr_db_crnn.cc C++预测源文件 + +``` + + 5. 启动调试 + + 上述步骤完成后就可以使用adb将文件push到手机上运行,步骤如下: + + ``` + # 执行编译,得到可执行文件ocr_db_crnn + # ocr_db_crnn可执行文件的使用方式为: + # ./ocr_db_crnn 检测模型文件 识别模型文件 测试图像路径 + make + # 将编译的可执行文件移动到debug文件夹中 + mv ocr_db_crnn ./debug/ + # 将debug文件夹push到手机上 + adb push debug /data/local/tmp/ + adb shell + cd /data/local/tmp/debug + export LD_LIBRARY_PATH=/data/local/tmp/debug:$LD_LIBRARY_PATH + ./ocr_db_crnn ch_det_mv3_db_opt.nb ch_rec_mv3_crnn_opt.nb ./11.jpg ppocr_keys_v1.txt + ``` + + 如果对代码做了修改,则需要重新编译并push到手机上。 + + 运行效果如下: + ![](..imgs/demo.png)