美文网首页Android开发经验谈Android开发OpenCv
〔两行哥〕OpenCV4Android入门教程之API系列(二)

〔两行哥〕OpenCV4Android入门教程之API系列(二)

作者: 两行哥 | 来源:发表于2018-05-29 18:08 被阅读37次

    继上篇我们完成了OpenCV4Android环境配置后(OpenCV4Android入门教程之API系列(一)),终于可以开始我们的OpenCV开发之旅。文中所有的示例,读者都可以在文末的Demo中进行尝试。

    一、初始化OpenCV

    从官方的Demo中来看,官方把初始化OpenCV的代码(如下)放在了Activity的OnResume()中,不过你也可以在OnCreate()进行初始化。创建初始化回调监听LoaderCallbackInterface对象,并传入到OpenCV异步初始化的方法initAsync()中。

            LoaderCallbackInterface loaderCallback = new BaseLoaderCallback(getApplicationContext()) {
                @Override
                public void onManagerConnected(int status) {
                    switch (status) {
                        case LoaderCallbackInterface.SUCCESS: {
                            Log.e(TAG, "OpenCV loaded successfully");
                        }
                        break;
                        default: {
                            super.onManagerConnected(status);
                        }
                        break;
                    }
                }
    
                @Override
                public void onPackageInstall(int operation, InstallCallbackInterface callback) {
    
                }
            };
            if (!OpenCVLoader.initDebug()) {
                Log.e(TAG, "Internal OpenCV library not found. Using OpenCV Manager for initialization");
                OpenCVLoader.initAsync(OpenCVLoader.OPENCV_VERSION, getApplicationContext(), loaderCallback);
            } else {
                Log.e(TAG, "OpenCV library found inside package. Using it!");
                loaderCallback.onManagerConnected(LoaderCallbackInterface.SUCCESS);
            }
    

    二、图片读取与写入

    (一)基本常识

    在开始读取与写入图片之前,我们先来普及一下图片的基本常识。

    1.图像通道数(Channels)

    通常我们看到的彩色图像是有三个通道的彩色图像,即RGB色彩模式下的彩色图片。一幅彩色图片上的每一个像素点的颜色都可以用R(Red红色)、G(Green绿色)、B(Blue蓝色)进行叠加混合而表示。我们对每个像素点上的R、G、B三种颜色的强度通过不同的数值进行区分,假设最小强度为0,最大强度为255,那么如果某个像素点上颜色为R0,G0,B255,则该像素点的颜色为纯蓝;如果某个像素点上的颜色为R255,G255,B0,则该像素点的颜色为纯黄色;如果某个像素点上的颜色为R255,G255,B255,则该像素的颜色为纯白色。至此,我们明白,所谓三通道图像,即图像含有R通道、G通道、B通道,图像中每个像素点都是由R、G、B进行叠加混合而来。
    那么单通道图像又是什么呢?如果基于上述的三通道图像,我们关闭其中的R与G通道,图片就会只剩下B通道。此时我们观察图像,整个图像就是一个有不同层次的蓝色图像。此时的图像就是单通道图像,也就是灰度图像,其中最暗的部分为0(显黑色),最亮的部分为255(最显蓝)。通常情况下,如果不额外说明,我们所指的灰度图像为黑白灰度图像,即最暗的部分为0(显黑色),最亮的部分为255(显白色),中间不同取值表示不同的灰色,类似老式黑白电视的画面。


    图1 RGB三原色
    图2 RGB三通道图像
    图3 单通道灰度图像

    那么我们会不会碰到其他通道数的图像呢?会的。比如,我们增加记录图像透明程度的A通道(ARGB色彩模式),A的取值表示某个像素点的透明程度。再比如某些高级相机,在拍摄的时候,记录了红外线强度,这里会有第四个通道用于记录其红外线强度,这种相机拍出来的原文件,就是四通道图像。

    2.OpenCV中图片的存储

    在OpenCV中,你会发现各种各样的Mat对象,Mat到底是什么呢?

    Mat对象是OpenCV中记录与存储图像的载体,为矩阵结构,在作用上可以类比为Android中的Bitmap。

    那么Mat对象是如何存储一张图像的呢?借用一下OpenCV读入图像及通道详解中的讲解图,先以一张灰度图为例:

    图4 灰度图片Mat存储结构
    如图4所示,这是一张单通道的灰度图片存储结构。在OpenCV中,像素点是最小的存储结构,一张图的左上角第一行,第一列的像素坐标为(Row0,Column0),以此类推,第N行第M列的像素坐标为(RowN,ColumnM)。
    那么再看看三通道的图像是如何存储的呢?
    图5 RGB三通道图片Mat存储结构
    如图5所示,与图4的单通道图像存储相似,但是每个像素点分成了三个通道进行存储,存储的顺序为B蓝色、G绿色、R红色。
    留意:OpenCV图像通道存储的顺序为BGR,并非常见的RGB排列,下文会详细说明。

    如果是N通道图像,Mat中存储每个像素点的Column,也会分成N个通道进行存储。

    3.Mat的类型

    在OpenCV中创建新Mat对象,有如下的方法:

    Mat mat = new Mat(300, 200, CvType.CV_8UC3);
    

    第一个参数为mat图像的宽,即Column的个数,第二个为mat图像的高,即Row的个数。第三个参数指定了Mat对象的类型,到底有哪些值呢?8UC3又是什么意思呢?

    常见的类型有CV_8UC1;CV_8UC3;CV_32SC3 ;CV_32FC3;CV_64FC3等,其通项表达式为:
    CV_<颜色深度>(S|U|F)C<通道数>

    1.颜色深度:8bit,16bit,32bit,64bit。存储每个像素使用的位数。

    2.S|U|F:
    S代表SignedInt,有符号整型;
    U代表UnsignedInt,无符号整型;
    F代表Float,单精度浮点型或双精度浮点型。

    结合1和2:
    8U - 无符号8位整型:0 ~ 255,即0 ~ 2^8-1
    8S - 有符号8位整型:-128 ~ 127
    16U - 无符号16位整型:0 ~ 65535
    16S - 有符号16位整型:-32768 ~ 32767
    32S - 有符号32位整型:0 ~ 65535
    32F - 单精度浮点数:0.0F ~ 1.0F
    64F - 双精度浮点数:0.0 ~ 1.0

    3.通道数:如灰度图片是单通道图像;RGB彩色图像是3通道图像;带Alpha通道的RGB图像是4通道图像。

    我们以8UC3为例,表示一张图片有三个通道(即B通道,G通道,R通道),每个通道颜色强度被分为256(2^8-1)个级别(0~255)。如这张图片中有一个像素点为(0,0,255),表示一个纯红的像素点。

    (二)图像读取与写入操作

    OpenCV读取本地文件的方法为:

    Mat mat = Imgcodecs.imread(String name, int flags);

    在进行OpenCV读取操作之前,请务必记得开启权限:

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

    如果是Android 6.0以上的版本,请进行动态权限申请,这里不再赘述。
    imread()方法共有两个参数,String name,name需要传入源文件的具体路径,如:Environment.getExternalStorageDirectory() + File.separator + “src.jpg”。第二个参数int flags,flags有哪些取值呢?在Imgcodecs中存在静态成员常量:

    CV_LOAD_IMAGE_UNCHANGED = -1//以图像原始属性读入
    CV_LOAD_IMAGE_GRAYSCALE = 0//以灰度图像读入
    CV_LOAD_IMAGE_COLOR = 1//以彩色图像读入
    CV_LOAD_IMAGE_ANYDEPTH = 2
    CV_LOAD_IMAGE_ANYCOLOR = 4
    CV_LOAD_IMAGE_IGNORE_ORIENTATION = 128

    我们通常使用的是CV_LOAD_IMAGE_GRAYSCALE 与CV_LOAD_IMAGE_COLOR两种,前者表示将图片加载为灰度图像(单通道灰度化图像),后者表示将图片加载为彩色图像(三通道彩色图像)。调用该方法后,图片即被加载,最终返回Mat对象。
    那我们对获取到的Mat对象经过一系列处理,需要存储我们修改后的Mat图像至本地文件,存储Mat对象至本地文件的方法为:

    Imgcodecs.imwrite(String fileName, Mat img);

    imwrite()方法共有两个参数,String fileName,name需要传入保存的文件具体路径,如:Environment.getExternalStorageDirectory() + File.separator + “dst.jpg”。第二个参数Mat img,img即为处理后的Mat对象。

    三、图像显示

    至此我们已经学会了OpenCV中图片的读取与写入,接下来我们来学习如何在UI上显示Mat对象。

    (一)Mat转换Bitmap

    Bitmap targetBitmap = Bitmap.createBitmap(srcMat.width(),srcMat.height(), Config.ARGB_8888);
    Utils.matToBitmap(srcMat, targetBitmap);

    我们需要将Mat对象转化为一个Bitmap对象,然后显示在UI上。首先,我们需要创建一个Bitmap对象(上文的targetBitmap),这个Bitmap对象必须与需要转换为Bitmap的Mat对象(上文的srcMat)等宽、等高(即上文在Bitmap构建方法中传入了srcMat.width()与srcMat.height()),然后调用OpenCV提供的Utils类的matToBitmap()方法,传入srcMat对象以及刚才创建的targetBitmap对象。调用完这个方法之后,上文的targetBitmap就是srcMat转换成的Bitmap。
    让我们把targetBitmap显示在ImageView上......等等,貌似和原图颜色不一样?


    图6 原图显示
    图7 通过Mat转Bitmap后的显示

    我们之前已经讲到,通常我们使用的三通道彩色图像是RGB三通道,排序也是RGB,而在OpenCV中存储图片使用的是BGR排序。之所以通过Mat转Bitmap后显示的图片会有点怪异,是因为原图中的R通道与B通道在OpenCV中被调换了,具体观感上来说,就是原图的R红色变成了B蓝色,而原图中的B蓝色变成了R红色。此时,上文的targetBitmap对象中的色彩通道顺序为BGR,而ImageView是RGB顺序显示图片的,从而出现了显示上的问题。再扩充一下,Utils.matToBitmap()方法转换出来的Bitmap对象,如果传入的Bitmap是ARGB_8888或ARGB_4444类型,那么转换后的Bitmap实际通道为ABGR,其中A为固定值255(即完全不透明)。
    那么问题来了,我们如何调整targetBitmap的色彩通道?这里提供一个调整Bitmap色彩通道的方法:

    public static Bitmap changeChannels(Bitmap bitmap) {
        int width = bitmap.getWidth();
        int height = bitmap.getHeight();
        int[] pixels = new int[width * height];
        bitmap.getPixels(pixels, 0, width, 0, 0, width, height);
        int index = 0;
        int channel1;
        int channel2;
        int channel3;
        int channel4;
        for (int row = 0; row < height; row++) {
            for (int col = 0; col < width; col++) {
                int pixel = pixels[index];
                channel1 = (pixel >> 24) & 0xff;//第一个通道为A值,实际上为固定值255
                channel2 = (pixel >> 16) & 0xff;//第二个通道为B值
                channel3 = (pixel >> 8) & 0xff;//第三个通道为G值
                channel4 = pixel & 0xff;//第四个通道为R值
                pixel = ((channel1 & 0xff) << 24) | ((channel4 & 0xff) << 16) | ((channel3 & 0xff) << 8) | (channel2 & 0xff);
                pixels[index] = pixel;
                index++;
            }
        }
        bitmap.setPixels(pixels, 0, width, 0, 0, width, height);
        return bitmap;
    }
    

    那么在显示targetBitmap之前,我们调用changeChannels()方法将其通道进行转换,然后再用ImageView进行显示。

    (二)色彩通道转换逻辑

    这里补充讲一下changeChannels()方法内部的逻辑。

    1.用int表示四通道的颜色
    int[] pixels = new int[width * height];
    bitmap.getPixels(pixels, 0, width, 0, 0, width, height);
    

    上述代码获取到了bitmap中所有的像素点的值,并存入了一个int[]数组中。这里的颜色值,怎么是一个int呢?int值如何再转换为ARGB的数值呢?

    在计算机系统中,数值一律用补码来表示和存储。原因在于,使用补码可以将符号位和数值域统一处理;同时,加法和减法也可以统一处理。
    1.正数的反码与其原码相同;补码也与其原码相同。
    2.负数的反码是对其原码逐位取反,但符号位除外;补码是对其反码+1。

    假设我们获取到了一个int值-65536,现在计算其代表的ARGB值。

    (1)取模

    |-65536| = 65536。

    (2)转化为二进制

    0000 0000 0000 0001 0000 0000 0000 0000

    (3)取反计算反码

    1111 1111 1111 1110 1111 1111 1111 1111

    (4)+1计算补码

    1111 1111 1111 1111 0000 0000 0000 0000

    (5)每四位为一组转换为十六进制

    FF FF 00 00

    备注:二进制1111等于十六进制F

    对于FF FF 00 00熟悉吗?如果是在ARGB四通道下,这不就是完全不透明的纯红色嘛!

    至此,我们搞明白了,上文代表颜色的int值,转换为二进制后的补码,从左到右,前8位是第一个通道,第9位至第16位是第二个通道,第17至24位是第三个通道,第25至32位是第四个通道。

    2.获取四个通道的值

    在通过bitmap.getPixels()方法获取到int[]数组后,如何对数组内每个像素的int值进行处理,从而获取到ARGB对应的是个通道的值呢?
    遍历int[]数组。因为int[]数组内部存储的像素点的值,从Bitmap对象的左上角的第1行第1列像素开始,至第1行第2列,......,至第1行第M列,至第2行第1列,至第2行第2列,......,至第2行第M列,......,至第N行第1列,至第N行第2列,......,至第N行第M列,依次存储。这里两行哥通过两个嵌套的循环进行遍历:

    for (int row = 0; row < height; row++) {
            for (int col = 0; col < width; col++) {
            ......
        }
    } 
    

    从第1行开始,一行一行地对列进行遍历,更贴近Mat内部数据的存储结构。当然,你也可以直接对int[]数组进行循环遍历,取出每个像素的int值。
    上文我们说过,代表颜色的int值,转换为二进制后的补码,从左到右,前8位是第一个通道,第9位至第16位是第二个通道,第17至24位是第三个通道,第25至32位是第四个通道。我们以int值-65536和通道二为例:

    //pixel:0000 0000 0000 0001 0000 0000 0000 0000
    channel2 = (pixel >> 16) & 0xff;
    
    (1)计算补码

    1111 1111 1111 1111 0000 0000 0000 0000

    (2)右移16位,补全前16位

    0000 0000 0000 0000 1111 1111 1111 1111

    (3)和0xff进行&(与)运算

    这里0xff是十六进制的ff,也可以用十进制对应的值(255)或二进制对应的值(1111 1111)进行代替,为了观察方便,我们用二进制的数字为例:

    0000 0000 0000 0000 1111 1111 1111 1111
    &
    0000 0000 0000 0000 0000 0000 1111 1111
    =
    0000 0000 0000 0000 0000 0000 1111 1111

    至此,我们第二个通道的值1111 1111已经获取到了,即十六进制的FF或十进制的255。

    3.交换组合四个通道的值

    ABGR四通道图像转换为ARGB四通道图像,我们只需要把ABGR图像的B通道和R通道进行交换,即第二个通道和第四个通道进行交换。

    pixel = ((channel1 & 0xff) << 24) | ((channel4 & 0xff) << 16) | ((channel3 & 0xff) << 8) | (channel2 & 0xff);
    

    如上文,我们之前对每个通道上的值进行了右移>>操作,现在需要使用左移<<复原,然后通过 | 运算,将(channel4 & 0xff) << 16放在第二个通道的位置,将channel2 & 0xff放在第四个通道的位置。

    四、直线绘制

    在Mat对象上绘制直线的逻辑如下:

    Imgproc.line(srcMat, new Point(0, 10), new Point(srcMat.width(), 10), new Scalar(0, 0, 255), 2, Imgproc.LINE_8, 0);
    

    第一参数srcMat表示在哪个Mat对象上绘制直线(比如从本地文件中,通过imread()方法读取的源文件Mat)。
    第二个和第三个参数表示直线的起点与终点。起点与终点的位置怎么表示呢?我们以srcMat的左上角为原点,向右为X轴正方向,向下为Y轴正方向建立坐标系。new Point(x,y)的构造方法传入在此坐标系中的坐标(x,y)即为Point的位置。
    第四个参数表示直线的颜色。如果srcMat为三通道BGR图像,则new Scalar(b,g,r)的构造方法传入B通道、G通道、R通道的颜色值。如上文new Scalar(0,0,255)即为一条红色线。如果srcMat为单通道灰度图像,则new Scaler(255)表示一条白色的线(此时传入的Scalar使用只有一个参数的构造方法)。
    第五个参数表示直线的宽度,以像素点为单位。
    第六个参数表示直线的绘制算法,有LINE_4、LINE_8和LINE_AA三种可以选择,一般我们使用LINE_8。具体有什么区别?请阅读两行哥的OpenCV算法系列〔两行哥〕OpenCV4Android入门教程之算法系列(一)
    第七个参数表示位移量,我们不需要位移,传入0即可。
    如图8,在原图顶部绘制了一条宽度为2的红色直线。

    图8 绘制直线

    五、矩形绘制

    首先,创建一个Rect对象:

    Rect rect = new Rect(10, 10, 300, 200);
    

    第一个参数10表示矩形左上角点的X坐标,第二个参数10表示左上角点的Y坐标,第三个参数300表示矩形宽度,第四个参数200表示矩形高度。或者通过下述方法创建Rect:

    Rect rect = new Rect(new Point(10, 10), new Point(310, 210));
    

    这里传入的两个Point对象,分别为矩形左上角的点和右下角的点。
    然后,我们开始矩形的绘制:

    Imgproc.rectangle(srcMat, rect.tl(), rect.br(), new Scalar(0, 0, 255), 2, Imgproc.LINE_8, 0);
    

    第二个参数表示矩形左上角的点(rect.tl()方法获取矩形左上角的Point对象)。
    第三个参数表示矩形右下角的点(rect.br()方法获取矩形右下角的Point对象)。
    其他参数同上文,这里不再赘述。
    如图9,在原图左上角绘制了一个红色矩形。


    图9 绘制矩形

    六、灰度化

    获取灰度化的图像,我们有两种方式:
    1.在图像加载的时候,调用imread()方法时,第二个参数flags直接传入CV_LOAD_IMAGE_GRAYSCALE;
    2.通过如下方法将彩色图像转换为灰度图像:

    Mat targetMat = new Mat();
    Imgproc.cvtColor(srcMat, targetMat, Imgproc.COLOR_BGR2GRAY);
    

    这里需要创建一个targetMat对象用于接收灰度化后的图像,并作为cvtColor()方法的第二个参数传入。
    如图10,显示了灰度图像targetMat。


    图10 灰度化

    七、像素取反

    反色的概念参考:反色。所谓反色,就是对原图中每个通道的值,修改为255 - 原值,即(r,g,b)的反色为(255 - r,255 - g,255 - b)。

    (一)单像素取反

    首先,我们讲解对单个像素逐个取反,最终完成全图片取反的逻辑:

    targetMat = srcMat.clone();
    int width = srcMat.width();
    int height = srcMat.height();
    int channels = srcMat.channels();
    
    //处理三通道图像
    int blue;
    int green;
    int red;
    if (channels == 3) {
        byte[] bgr = new byte[channels];
        for (int i = 0; i < height; i++) {
            for (int j = 0; j < width; j++) {
                srcMat.get(i, j, bgr);
                blue = bgr[0] & 0xff;
                green = bgr[1] & 0xff;
                red = bgr[2] & 0xff;
                //取反
                bgr[0] = (byte) (255 - blue);
                bgr[1] = (byte) (255 - green);
                bgr[2] = (byte) (255 - red);
                targetMat.put(i, j, bgr);
            }
        }
    }
    //处理灰度图像
    int gray;
    if (channels == 1) {
        byte[] g = new byte[1];
        for (int i = 0; i < height; i++) {
            for (int j = 0; j < width; j++) {
                srcMat.get(i, j, g);
                gray = g[0] & 0xff;
                g[0] = (byte) (255 - gray);
                targetMat.put(i, j, g);
            }
        }
    }
    

    首先看srcMat.get(i,j,bgr)方法。上文我们已经讲过Mat中图像存储的数据结构。这个的get()方法获取了RowI和ColumnJ位置的像素点,将获取到的各个通道的值存储到byte[channels]的数组中。如果为单通道图像,则数组长度为1,如果为三通道图像,则数组长度为3,数组中分别存储了B通道值、G通道值和R通道值。我们分别对其取反,重新放入原数组,并调用targetMat.put()方法将其存储起来。
    如图11,显示了对原图取反后的图像。


    图11 原图取反色
    (二)全像素取反

    在(一)中对像素逐个取反,然后逐个存储效率并不高,因为get()方法和put()方法都进行了JNI操作,在循环中多次调用JNI操作是非常耗时的。这里提出一个优化方法:

    targetMat = srcMat.clone();
    int width = srcMat.width();
    int height = srcMat.height();
    int channels = srcMat.channels();
    
    //处理三通道图像
    int blue;
    int green;
    int red;
    if (channels == 3) {
        byte[] bgr = new byte[width * height * channels];
        srcMat.get(0, 0, bgr);
        for (int i = 0; i < height; i++) {
            for (int j = 0; j < width; j++) {
                blue = bgr[i * width * channels + j * channels] & 0xff;
                green = bgr[i * width * channels + j * channels + 1] & 0xff;
                red = bgr[i * width * channels + j * channels + 2] & 0xff;
                bgr[i * width * channels + j * channels] = (byte) (255 - blue);
                bgr[i * width * channels + j * channels + 1] = (byte) (255 - green);
                bgr[i * width * channels + j * channels + 2] = (byte) (255 - red);
            }
        }
        targetMat.put(0, 0, bgr);
    
    }
    
    //处理灰度图像
    int gray;
    if (channels == 1) {
        byte[] g = new byte[width * height];
        for (int i = 0; i < height; i++) {
            for (int j = 0; j < width; j++) {
                gray = g[i * width + j] & 0xff;
                g[i * width + j] = (byte) (255 - gray);
            }
        }
        targetMat.put(0, 0, g);
    }
    

    在这里我们创建了一个长度为width * height * channels的byte[]数组bgr,调用了 srcMat.get(0,0,bgr)获取到了全部的像素值。像素值在bgr中是如何存储的呢?

    [ 第1行第1列B值,第1行第1列G值,第1行第1列R值,第1行第2列B值,第1行第2列G值,第1行第2列R值,...,第2行第1列B值,第2行第1列G值,第2行第1列R值,第2行第2列B值,第2行第2列G值,第2行第2列R值,...,第N行第M列B值,第N行第M列G值,第N行第M列R值 ]

    我们只要依次取出这些像素,并进行取反操作,处理完所有像素以后,调用targetMat.put(0,0,bgr)方法全部存储就好了。在这里两行哥采用了双层嵌套循环,更贴近Mat内部数据的存储结构。当然,你也可以直接对bgr数组进行一次循环遍历,对每个像素进行取反,更简单更暴力。
    上述逻辑一共进行了两次JNI操作,一次是get(),一次是put(),读者可以自行对比一下单像素取反和全部取反的效率,看看全像素取反可以快多少。

    上文所有的示例都可以在这里找到源码:

    源码下载地址
    请将源码中的img文件夹中的图片拷贝到手机存储中,然后在源码中配置图片的路径,就可以运行Demo了。

    图12 img文件夹
    在MainActivity.java中配置图片路径:
    public static final String mBasePath = Environment.getExternalStorageDirectory() 
    + File.separator 
    + "OpenCV4AndroidDemo" + File.separator;
    
    public static final String mImgName = "01.jpg";
    

    相关文章

      网友评论

      • 0c306f42f116:我看得晕晕的,个人还是接触的少感觉很抽象.
        隔段时间再学习一遍.
        我来拉人学习一下@Master_文

      本文标题:〔两行哥〕OpenCV4Android入门教程之API系列(二)

      本文链接:https://www.haomeiwen.com/subject/wliajftx.html