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

继上篇我们完成了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(显白色),中间不同取值表示不同的灰色,类似老式黑白电视的画面。


〔两行哥〕OpenCV4Android入门教程之API系列(二)_第1张图片
图1 RGB三原色

〔两行哥〕OpenCV4Android入门教程之API系列(二)_第2张图片
图2 RGB三通道图像

〔两行哥〕OpenCV4Android入门教程之API系列(二)_第3张图片
图3 单通道灰度图像

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

2.OpenCV中图片的存储

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

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

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

〔两行哥〕OpenCV4Android入门教程之API系列(二)_第4张图片
图4 灰度图片Mat存储结构

如图4所示,这是一张单通道的灰度图片存储结构。在OpenCV中,像素点是最小的存储结构,一张图的左上角第一行,第一列的像素坐标为(Row0,Column0),以此类推,第N行第M列的像素坐标为(RowN,ColumnM)。
那么再看看三通道的图像是如何存储的呢?
〔两行哥〕OpenCV4Android入门教程之API系列(二)_第5张图片
图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位整型:-2^31 ~ 2^31-1
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读取操作之前,请务必记得开启权限:



如果是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上......等等,貌似和原图颜色不一样?


〔两行哥〕OpenCV4Android入门教程之API系列(二)_第6张图片
图6 原图显示

〔两行哥〕OpenCV4Android入门教程之API系列(二)_第7张图片
图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的红色直线。

〔两行哥〕OpenCV4Android入门教程之API系列(二)_第8张图片
图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,在原图左上角绘制了一个红色矩形。


〔两行哥〕OpenCV4Android入门教程之API系列(二)_第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。


〔两行哥〕OpenCV4Android入门教程之API系列(二)_第10张图片
图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,显示了对原图取反后的图像。


〔两行哥〕OpenCV4Android入门教程之API系列(二)_第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了。

〔两行哥〕OpenCV4Android入门教程之API系列(二)_第12张图片
图12 img文件夹

在MainActivity.java中配置图片路径:

public static final String mBasePath = Environment.getExternalStorageDirectory() 
+ File.separator 
+ "OpenCV4AndroidDemo" + File.separator;

public static final String mImgName = "01.jpg";

你可能感兴趣的:(〔两行哥〕OpenCV4Android入门教程之API系列(二))