【安卓随笔】引入OpenCV进行NDK开发之图片传递(案例:文字水印)

来源:互联网 发布:最好的源码下载网站 编辑:程序博客网 时间:2024/06/06 02:24

       失踪人口回归!距离上次承诺的更新,一下子鸽了半年,从文章的点击量来看,大家还是比较钟情OpenCV相关的开发的,那么在今天这个1024程序猿节日(等审核过了估计就1025了- -b),我也给广大刚刚入行的开发者送上一份微薄的礼物,那就是新的一篇关于OpenCV在安卓开发中的使用教程!

       首先强调一点,这篇博文只是针对于刚刚入门的新手的,本身没有比较深的难度,更不具有研究性质,本人水平也比较有限,只是希望能帮助一些需要帮助的同学,再日后会逐渐提升博文的深度和广度,和大家一起进步。

       关于我之前人脸识别的博文,其中只介绍了如何使用OpenCV的Java层做相关的开发,但众所周知OpenCV原本是C/C++编写的,在Java层的支持实际上是非常有限的,如果想进行一些更加高级的操作,还是需要用C++来进行开发,那么我就来介绍一下怎么在NDK的开发中使用OpenCV,如果你对NDK开发还不太熟悉,请先浏览我之前的博文,这些教程是一个循序渐进的过程。

       今天我们不写什么高大上的功能,当然也不能只写一个Hello World级别的程序来糊弄大家,就简单写一下如何使用OpenCV给图片加文字水印吧,其实这个功能不是重点,重点是分享一些我平时总结的JNI和Java之间的图片传递问题的解决方案,我会用多种方法来实现这个功能,具体使用哪种,大家就根据性能和需求来自己决定吧。(如果你是为了添加水印而来的可能要失望了,这个不是本文的重点,而且这里的水印也不支持中文字符...)

       首先我们先去官网下载一个OpenCV最新的安卓开发包,截止本文发稿时最新版为3.3,下载完成后将其解压到一个无中文的路径下,解压完毕后,打开OpenCV330\sdk\native\libs目录,将其中各种处理器类型的libopencv_java3.so全部提取出来,放入jniLibs中,如图所示:


此时我们就将OpenCV的so库引入了项目,但不要着急,想在NDK中使用它,还需要重要的一步,就是在CMake文件中进行配置,引入方法也很简单,只需要两行:

//添加一个名称为lib_opencv的library
add_library(lib_opencv SHARED IMPORTED )//设置lib_opencv的为刚才引入的so文件,包含了一些相对路径的写法set_target_properties(lib_opencv PROPERTIES IMPORTED_LOCATION ${CMAKE_CURRENT_SOURCE_DIR}/src/main/jniLibs/${ANDROID_ABI}/libopencv_java3.so)

当然引入这些还是不够的,so文件里面只是具体的函数实现,我们还需要引入所有函数的头文件才可以正常调用,于是再在Cmake中添加:

include_directories(D:/OpenCV330/sdk/native/jni/include)

将刚才下载的OpenCV包中的include文件夹引入,这里的路径以你的硬盘为准,这就包含了所有的函数声明,我们现在就可以正常的调用它了,最后附上完整的Cmake文件:

cmake_minimum_required(VERSION 3.4.1)include_directories(D:/OpenCV330/sdk/native/jni/include)add_library(lib_opencv SHARED IMPORTED )set_target_properties(lib_opencv PROPERTIES IMPORTED_LOCATION ${CMAKE_CURRENT_SOURCE_DIR}/src/main/jniLibs/${ANDROID_ABI}/libopencv_java3.so)add_library(native-lib SHARED src/main/cpp/native-lib.cpp )find_library(log-lib log )target_link_libraries(native-lib lib_opencv ${log-lib} )

然后我们在布局上做一些准备工作,非常简单,一个输入框用语自定义水印文字,三个按钮用于三种不同的方法添加水印到图片,一个ImageView用于展示结果:


到此,准备工作算是全部完成了,我们终于可以在cpp文件里大展拳脚啦!由于上次已经介绍过NDK开发的基础知识,本次就不再详细说明,只先贴一些关键代码来带过。

在Java层,我们直接声明3个native方法,在C++中写具体的实现:

public class NdkLoader {    static {        System.loadLibrary("native-lib");    }    public static native int[] addText2Picture(int[] pixels_, int width, int height, String content);    public static native int addText2Picture2(long output, String content);    public static native int addText2Picture3(String content, String input, String output);}
本次我们将以3种数据传输方式来完成这个功能,分别对应了3种不同的函数:

第一种是在Java层把一个Bitmap的像素数据以int[]传入JNI,JNI处理后再返回一个int[]的像素数据。(Java层无需引入OpenCV)

第二种是Java层调用Java层的OpenCV,创建一个空的Mat对象,将此对象的地址传入JNI,JNI处理后,形参改变了实参。(Java层需要引入OpenCV)
第三种是Java层只传入输入图片在手机中的路径和输出的字符串路径,JNI去读取该文件,并将新的图片输出为新的文件。(Java层无需引入OpenCV)


方案一

现在我们先来介绍第一种方法,关于水印部分,每种方法里的实现也都一样,所以也只在这里进行讲解。我们先在Java层读取一个Bitmap对象,然后提取它的像素值传入JNI:

  Bitmap bmp = BitmapFactory.decodeResource(getResources(), R.mipmap.zelda);                int w = bmp.getWidth();                int h = bmp.getHeight();                int[] pixels = new int[w * h];                bmp.getPixels(pixels, 0, w, 0, 0, w, h);                String content = editText.getText().toString();                int[] resultInt = NdkLoader.addText2Picture(pixels, w, h, content);
实现方法比较简单,获取bitmap对象的宽高,然后申请长度为长x宽的数组,使用bitmap.getPixels方法提取像素。addText2Picture的第一个参数是像素数组,第二个参数为宽度,第三个参数为高度,最后一个参数是从输入框获取的水印内容。

然后我们重点来看C++代码:

JNIEXPORT jintArray JNICALLJava_com_lbw_opencvaddtextmark_NdkLoader_addText2Picture(        JNIEnv *env,        jobject, jintArray pixels_,        jint w, jint h, jstring textString) {    const char *text = env->GetStringUTFChars(textString, 0);    string content = text;    jint *pixels = env->GetIntArrayElements(pixels_, NULL);    if (pixels == NULL) {        return NULL;    }    Mat src(h, w, CV_8UC4, pixels);    int width = src.cols;    int height = src.rows;    int margin = 10;    int baseline;    Size srcSize = getTextSize(content, FONT_HERSHEY_COMPLEX, 2, 2, &baseline);    cv::Point point;    point.x = width - srcSize.width - margin;    point.y = height - margin;    //Scalar BGR    putText(src, content, point, FONT_HERSHEY_COMPLEX, 2, cv::Scalar(94, 206, 165, 255), 2, 8, 0);    int size = w * h;    jintArray result = env->NewIntArray(size);    env->SetIntArrayRegion(result, 0, size, pixels);    env->ReleaseIntArrayElements(pixels_, pixels, 0);    env->ReleaseStringUTFChars(textString, text);    return result;}
首先接收了传进来的字符串内容,然后定义一个jint的指针,指向传入的jintArray,然后调用Mat(int rows,int cols,int type,void *data)构造方法,这四个参数的意义比较重大,下面详解一下:第一个参数要求传rows(行数),而我传的是高度,这里稍微用膝盖思考一下就会发现,一个矩阵的行数就是高度- -b,同理列数就是宽度,这个参数是非常常见的,希望大家可以记住这些常识。。。然后是type,我们传入的是CV_8U4C,就代表这是一个8位无符号4通道的带透明色的RGB图像,那么一会儿我们设置字体颜色时也要有四个颜色参数。*data我们传入的是pixels,就是jintarray的指针,这个没有什么好解释的。这样我们就创建了一个包含图像的Mat矩阵,Mat是OpenCV中非常非常常用的对象,希望大家多多掌握它的用法。

在添加水印之前,为了确保图片足够容纳下文字,我们必须先计算出水印的宽高,再决定在什么坐标上进行绘制,如果你看到了这里,对坐标的计算应该是没有太大问题,因为OpenCV的坐标系和安卓是一样的,左上角为原点坐标,右为X轴正方向,下为Y轴正方向。然后我们调用getTextSize方法,指定文字内容,字体,单位尺寸,线条宽度等元素后获得计算结果,用Size对象接收。

然后我们调用putText函数,先传入要添加水印的mat对象,然后传入文字内容,再传入坐标点、文字字体、单位尺寸、颜色、线条宽度等参数,坐标点指的是文字的左下角,要特别注意,我准备将水印放在整个图片的又下角,像新浪微博一样,point的创建应该无需过多解释,稍微做过一些自定义控件应该都会明白的。这里的参数范围大家可以自己去实验一下,至于字体这里支持7,8种,很遗憾这些有限的字体里并不支持中文,所以水印是无法使用中文的,如果在windows上开发,你会查到很多让它支持中文的方法,比如使用FreeType,当然现在是在安卓上做的开发,我并未研究怎么去编译并引入FreeType的源码,所以本次就先不讨论怎么在这里让它支持中文,如果你有好的办法可以在下面留言分享给大家。重点要强调的是颜色的设置,需要创建一个Scalar对象,因为我们刚才设置的图片为4通道,这里也需要4个参数,前三位是颜色,第四位为透明度,但你一定要扭转日常开发的思维,这里的前三位颜色并不是RGB顺序,而是BGR顺序,至于原因估计就是作者这种超级大神的个人习惯问题吧 - -。最后我们再创建一个jintArray,并将其赋值为修改后的像素值返回,Java层接收后做简单处理就可以显示了。

  int[] resultInt = NdkLoader.addText2Picture(pixels, w, h, content);                if (resultInt != null) {                    Bitmap resultImg = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888);                    resultImg.setPixels(resultInt, 0, w, 0, 0, w, h);                    imageView.setImageBitmap(resultImg);                }

方案二
方案二比起方案一,是一种效率更高的办法,但是实际写起来会麻烦一些,因为在Java层必须也引入openCV,首先还是老套路,将OpenCV330\sdk\java作为Module引入,并为主工程添加依赖,但这次不同的是我们要使用Java层的Mat对象,官方给的做法是先安装他们指定的APK,作为一个外联库用,这个当然是绝对不可以接受的,这里我教大家怎么摆脱掉那个额外的APK。首先创建一个OpenCV的BaseLoaderCallback回调,用于得知so库是否加载成功

    private BaseLoaderCallback mOpenCVCallBack = new BaseLoaderCallback(this) {        @Override        public void onManagerConnected(int status) {            switch (status) {                case LoaderCallbackInterface.SUCCESS: {                    button2.setEnabled(true);                }                break;                default: {                    super.onManagerConnected(status);                }                break;            }        }    };

然后我们在onResume方法中手动加载so库,完成后回调onManagerConnected方法:

   @Override    protected void onResume() {        super.onResume();        if (OpenCVLoader.initDebug()) {            System.loadLibrary("opencv_java3");            mOpenCVCallBack.onManagerConnected(LoaderCallbackInterface.SUCCESS);        }    }


当开始定义的回调里收到了信息,就说明加载完成,于是就可以创建Mat对象了,也不需要什么额外的APK了,我这里水印是点按钮触发的,加载完成后才让该按钮可点击。

java:

 Mat src = new Mat();                Bitmap bmp2 = BitmapFactory.decodeResource(getResources(), R.mipmap.ciri);                Utils.bitmapToMat(bmp2, src);                int i = NdkLoader.addText2Picture2(src.getNativeObjAddr(), editText.getText().toString());                if (i == 1) {                    Utils.matToBitmap(src, bmp2);                    imageView.setImageBitmap(bmp2);                }
C++:

JNIEXPORT jint JNICALLJava_com_lbw_opencvaddtextmark_NdkLoader_addText2Picture2(JNIEnv *env, jclass type, jlong output,                                                          jstring textString) {    const char *text = env->GetStringUTFChars(textString, 0);    string content = text;    Mat src = *(Mat *) output;    int width = src.cols;    int height = src.rows;    int margin = 10;    int baseline;    Size srcSize = getTextSize(content, FONT_HERSHEY_COMPLEX, 2, 2, &baseline);    cv::Point point;    point.x = width - srcSize.width - margin;    point.y = height - margin;    putText(src, content, point, FONT_HERSHEY_COMPLEX, 2, Scalar(129, 58, 98, 255), 2, 8, 0);    env->ReleaseStringUTFChars(textString, text);    return 1;}

首先我们在Java层读取了一个Bitmap,创建了一个空的Mat对象,然后使用OpenCV SDK的Utils.bitmapToMat方法,将bitmap内的图像赋给mat对象,然后调用Mat.getNativeObjAddr将对象的地址传入,C++中先将这个地址转为Mat指针(将指针指向该内存地址),再用*取地址上的内容,这样就拿到了Mat对象,这里不是二级指针的意思不要误会,本文也不过多讨论C++的语法问题...后面的水印操作跟之前一样不再解释,我们运行一下来看效果就好:



方案三


这种方案纯粹在C++中进行IO操作,可以一直读写文件,如果只要文件不展示结果则Java层无需处理其他事物。如果你的代码想在更多平台上跑,如iOS,Windows等,这样写也很容易移植。

Java:

  File file = new File(Environment.getExternalStorageDirectory().getPath() + "/lara.jpg");                if (!file.exists()) {                    copyFilesFromAssets(getApplicationContext(), "lara.jpg", Environment.getExternalStorageDirectory().getPath() + "/lara.jpg");                }                int reusult = NdkLoader.addText2Picture3(editText.getText().toString(),                        Environment.getExternalStorageDirectory().getPath() + "/lara.jpg",                        Environment.getExternalStorageDirectory().getPath() + "/lara_new.jpg");                if (reusult == 1) {                    Bitmap bmp3 = BitmapFactory.decodeFile(Environment.getExternalStorageDirectory().getPath() + "/lara_new.jpg");                    imageView.setImageBitmap(bmp3);                }

首先Java层先把一张图片从assets文件中复制到了SD卡根目录,作为实验用品,Demo里需要获取存储读写权限,请务必允许。然后只需传入输入路径和输出路径即可。

C++:

JNIEXPORT jint JNICALLJava_com_lbw_opencvaddtextmark_NdkLoader_addText2Picture3(JNIEnv *env, jclass type,                                                          jstring textString, jstring input,                                                          jstring output) {    const char *content = env->GetStringUTFChars(textString, 0);    const char *inputPath = env->GetStringUTFChars(input, 0);    const char *outputPath = env->GetStringUTFChars(output, 0);    Mat src = imread(inputPath);    if (src.data == NULL) {        return 0;    }    int width = src.cols;    int height = src.rows;    int margin = 10;    int baseline;    Size srcSize = getTextSize(content, FONT_HERSHEY_COMPLEX, 2, 2, &baseline);    cv::Point point;    point.x = width - srcSize.width - margin;    point.y = height - margin;    putText(src, content, point, FONT_HERSHEY_COMPLEX, 2, Scalar(255, 255, 255, 255), 2, 8, 0);    imwrite(outputPath, src);    env->ReleaseStringUTFChars(textString, content);    env->ReleaseStringUTFChars(input, inputPath);    env->ReleaseStringUTFChars(output, outputPath);    return 1;}

C++的代码也很清晰,只介绍一下imread和imwrite函数,顾名思义,imread是根据路径读取图片文件,返回值是Mat,imwrite是根据mat对象和路径写入图片文件,这里也都是需要获取安卓的存储读写权限的。拿到了Mat图片,我们再如法炮制,添加水印。Java层去该输出地址读取图片展示即可,下面运行一下:




尾声

       到这里,本期的分享也就结束了,可能内容有些枯燥,所以建议大家编程之余多玩玩游戏放松一下,我也就选择了几张游戏女神图片作为案例,工作压力大也一定要学会怎么去放松。之前每期的最后我都会立一个flag,提醒自己一定要继续更新博客,这次也不例外,下回我会分享一些好玩的不枯燥的东西,同时又包含着一些技术点,那就是教大家如何使用Nintendo Switch 的JoyCon手柄在一台没有蓝牙的PC上玩游戏!将通过Android和JavaSE来实现,我会尽快写出来的.对于第一期迟迟没上传的源码我在这里表示抱歉,年代久远我已经找不到了,评论好多朋友都炸锅了...也许日后有空我会重新写一遍补上吧,现在确实是太忙.....

       最后附上本期内容的Demo源码,大家自行下载(由于空间问题,demo里只保留了armv7的环境,并且删除了opencv java层的module,请大家自行在官网下载引入)

       OpenCV下载地址

       Demo下载

       pan点baidu点com/s/1qY0sflq

       密码:jpkd