继上篇我们完成了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(显白色),中间不同取值表示不同的灰色,类似老式黑白电视的画面。
那么我们会不会碰到其他通道数的图像呢?会的。比如,我们增加记录图像透明程度的A通道(ARGB色彩模式),A的取值表示某个像素点的透明程度。再比如某些高级相机,在拍摄的时候,记录了红外线强度,这里会有第四个通道用于记录其红外线强度,这种相机拍出来的原文件,就是四通道图像。
2.OpenCV中图片的存储
在OpenCV中,你会发现各种各样的Mat对象,Mat到底是什么呢?
Mat对象是OpenCV中记录与存储图像的载体,为矩阵结构,在作用上可以类比为Android中的Bitmap。
那么Mat对象是如何存储一张图像的呢?借用一下OpenCV读入图像及通道详解中的讲解图,先以一张灰度图为例:
如图4所示,这是一张单通道的灰度图片存储结构。在OpenCV中,像素点是最小的存储结构,一张图的左上角第一行,第一列的像素坐标为(Row0,Column0),以此类推,第N行第M列的像素坐标为(RowN,ColumnM)。
那么再看看三通道的图像是如何存储的呢?
如图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上......等等,貌似和原图颜色不一样?
我们之前已经讲到,通常我们使用的三通道彩色图像是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的红色直线。
五、矩形绘制
首先,创建一个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,在原图左上角绘制了一个红色矩形。
六、灰度化
获取灰度化的图像,我们有两种方式:
1.在图像加载的时候,调用imread()方法时,第二个参数flags直接传入CV_LOAD_IMAGE_GRAYSCALE;
2.通过如下方法将彩色图像转换为灰度图像:
Mat targetMat = new Mat();
Imgproc.cvtColor(srcMat, targetMat, Imgproc.COLOR_BGR2GRAY);
这里需要创建一个targetMat对象用于接收灰度化后的图像,并作为cvtColor()方法的第二个参数传入。
如图10,显示了灰度图像targetMat。
七、像素取反
反色的概念参考:反色。所谓反色,就是对原图中每个通道的值,修改为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,显示了对原图取反后的图像。
(二)全像素取反
在(一)中对像素逐个取反,然后逐个存储效率并不高,因为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了。
在MainActivity.java中配置图片路径:
public static final String mBasePath = Environment.getExternalStorageDirectory()
+ File.separator
+ "OpenCV4AndroidDemo" + File.separator;
public static final String mImgName = "01.jpg";