logo头像
Snippet 博客主题

Android滤镜效果实现及原理分析

本文于506天之前发表,文中内容可能已经过时。

Android在处理图片时,最常使用到的数据结构是位图(Bitmap),它包含了一张图片所有的数据。整个图片都是由点阵和颜色值组成的,所谓点阵就是一个包含像素的矩阵,每一个元素对应着图片的一个像素。而颜色值——ARGB,分别对应着透明度、红、绿、蓝这四个通道分量,他们共同决定了每个像素点显示的颜色。下图是ARGB的模型图。
这里写图片描述

色彩矩阵分析

在Android中,系统使用一个颜色矩阵-ColorMatrix来处理图像的色彩效果。对于图像的每个像素点,都有一个颜色分量矩阵用来保存颜色的RGBA值(下图矩阵C),Android中的颜色矩阵是一个 4x5 的数字矩阵,它用来对图片的色彩进行处理(下图矩阵A)。
这里写图片描述
在Android系统中,如果想要改变一张图像的色彩显示效果,可以使用矩阵的乘法运算来修改颜色分量矩阵的值。上面矩阵A就是一个 4x5 的颜色矩阵。在Android中,它会以一维数组的形式来存储[a,b,d,e,f,g,h,i,j,k,l,m,n,o,p,q,r,s,t],而C则是一个颜色矩阵分量。在处理图像时,使用矩阵乘法运算AC来处理颜色分量矩阵,如下:
这里写图片描述
利用线性代数知识可以得到如下等式:

1
2
3
4
R1 = aR + bG + cB + dA + e;
G1 = fR + gG + hB + iA + j;
B1 = kR + lG + mB + nA + o;
A1 = pR + qG + rB + sA + t;

从上面的等式可以发现:

  • 第一行的 abcde 用来决定新的颜色值中的R——红色
  • 第二行的 fghij 用来决定新的颜色值中的G——绿色
  • 第三行的 klmno 用来决定新的颜色值中的B——蓝色
  • 第四行的 pqrst 用来决定新的颜色值中的A——透明度
  • 矩阵A中第五列——ejot 值分别用来决定每个分量中的 offset ,即偏移量
    这样一说明,大家对这个公司就明白了。

初始颜色矩阵

接下来,我们重新看一下矩阵变换的计算公式,以R分量为例。

1
R1 = aR + bG + cB + dA + e;

如果令 a=1,b、c、d、e都等于0,则有 R1=R 。同理对第二、三、四、行进行操作,可以构造出一个矩阵,如下:
这里写图片描述
把这个矩阵代入公式 R=AC,根据矩阵乘法运算法则,可得R1=R,G1=G,B1=B,A1=A。即不会对原有颜色进行任何修改,所以这个矩阵通常被用来作为初始颜色矩阵。

改变颜色值

如果想要改变颜色值的时候,通常有两种方法:

  • 改变颜色的 offset(偏移量)的值;
  • 改变对应 RGBA 值的系数。

1.改变偏移量

从前面的分析中可知,改变颜色的偏移量就是改变颜色矩阵的第五列的值,其他保持初始矩阵的值即可。如下示例:
这里写图片描述
上面的操作中改变了 R、G 对应的颜色偏移量,那么结果就是图像的红色和绿色分量增加了100,即整体色调偏黄显示。
这里写图片描述
其中,左边为原图,右边为改变 偏移量后的效果。

2.改变颜色系数

假如我们队颜色矩阵做如下操作。
这里写图片描述
改变 G 分量对应的系数 g 的值,增加到2倍,这样在矩阵运算后,图像会整体色调偏绿显示。
这里写图片描述

通过前面的分析,我们知道调整颜色矩阵可以改变图像的色彩效果,图像的色彩处理很大程度上就是在寻找处理图像的颜色矩阵。

Android实例

下面,我们着手写一个demo,模拟一个 4x5 的颜色矩阵来体验一下上面对颜色矩阵的分析。效果如下:
这里写图片描述

关键代码是将 4x5 矩阵转换成一维数组,然后再将这一维数组设置到ColorMatrix类里去,请看代码:

1
2
3
4
5
6
7
8
9
10
11
12
//将矩阵设置到图像
private void setImageMatrix() {
Bitmap bmp = Bitmap.createBitmap(bitmap.getWidth(), bitmap.getHeight(), Bitmap.Config.ARGB_8888);
ColorMatrix colorMatrix = new ColorMatrix();
colorMatrix.set(mColorMatrix);//将一维数组设置到ColorMatrix
Canvas canvas = new Canvas(bmp);
Paint paint = new Paint();
paint.setColorFilter(new ColorMatrixColorFilter(colorMatrix));
canvas.drawBitmap(bitmap, 0, 0, paint);
iv_photo.setImageBitmap(bmp);
}

这个demo里面的代码比较简单,我在这里就全部贴出来了,先上xml布局:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="com.example.deeson.mycolormatrix.MainActivity"
android:orientation="vertical">
<ImageView
android:id="@+id/iv_photo"
android:layout_width="300dp"
android:layout_height="0dp"
android:layout_weight="3"
android:layout_gravity="center_horizontal"/>
<GridLayout
android:id="@+id/matrix_layout"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="4"
android:columnCount="5"
android:rowCount="4">
</GridLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<Button
android:id="@+id/btn_change"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="change"/>
<Button
android:id="@+id/btn_reset"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="reset"/>
</LinearLayout>
</LinearLayout>

在 MainActivity 类这里有一个地方要注意的就是,我们无法在 onCreate() 方法中获得 4x5 矩阵视图的宽高值,所以通过 View 的 post() 方法,在视图创建完毕后获得其宽高值。如下:

1
2
3
4
5
6
7
8
9
10
matrixLayout.post(new Runnable() {
@Override
public void run() {
mEtWidth = matrixLayout.getWidth() / 5;
mEtHeight = matrixLayout.getHeight() / 4;
addEts();
initMatrix();
}
});

接下来是 MainActivity 类的全部代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
public class MainActivity extends AppCompatActivity implements View.OnClickListener {
Bitmap bitmap;
ImageView iv_photo;
GridLayout matrixLayout;
//每个edittext的宽高
int mEtWidth;
int mEtHeight;
//保存20个edittext
EditText[] mEts = new EditText[20];
//一维数组保存20个矩阵值
float[] mColorMatrix = new float[20];
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.iv_model);
iv_photo = (ImageView) findViewById(R.id.iv_photo);
matrixLayout = (GridLayout) findViewById(R.id.matrix_layout);
Button btn_change = (Button) findViewById(R.id.btn_change);
Button btn_reset = (Button) findViewById(R.id.btn_reset);
btn_change.setOnClickListener(this);
btn_reset.setOnClickListener(this);
iv_photo.setImageBitmap(bitmap);
//我们无法在onCreate()方法中获得视图的宽高值,所以通过View的post()方法,在视图创建完毕后获得其宽高值
matrixLayout.post(new Runnable() {
@Override
public void run() {
mEtWidth = matrixLayout.getWidth() / 5;
mEtHeight = matrixLayout.getHeight() / 4;
addEts();
initMatrix();
}
});
}
//动态添加edittext
private void addEts() {
for (int i = 0; i < 20; i++) {
EditText et = new EditText(this);
et.setInputType(InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_DECIMAL);
mEts[i] = et;
matrixLayout.addView(et, mEtWidth, mEtHeight);
}
}
//初始化颜色矩阵
private void initMatrix() {
for (int i = 0; i < 20; i++) {
if (i % 6 == 0) {
mEts[i].setText(String.valueOf(1));
} else {
mEts[i].setText(String.valueOf(0));
}
}
}
//获取矩阵值
private void getMatrix() {
for (int i = 0; i < 20; i++) {
String matrix = mEts[i].getText().toString();
boolean isNone = null == matrix || "".equals(matrix);
mColorMatrix[i] = isNone ? 0.0f : Float.valueOf(matrix);
if (isNone) {
mEts[i].setText("0");
}
}
}
//将矩阵设置到图像
private void setImageMatrix() {
Bitmap bmp = Bitmap.createBitmap(bitmap.getWidth(), bitmap.getHeight(), Bitmap.Config.ARGB_8888);
ColorMatrix colorMatrix = new ColorMatrix();
colorMatrix.set(mColorMatrix);//将一维数组设置到ColorMatrix
Canvas canvas = new Canvas(bmp);
Paint paint = new Paint();
paint.setColorFilter(new ColorMatrixColorFilter(colorMatrix));
canvas.drawBitmap(bitmap, 0, 0, paint);
iv_photo.setImageBitmap(bmp);
}
@Override
public void onClick(View v) {
switch (v.getId()) {
case R.id.btn_change:
//作用矩阵效果
break;
case R.id.btn_reset:
//重置矩阵效果
initMatrix();
break;
}
//作用矩阵效果
getMatrix();
setImageMatrix();
}
}

如果有人不想自己敲代码的,可以到下面地址下载:Demo下载地址

图像的色光属性

在色彩处理中,通常使用以下三个角度来描述一个图像。

  • 色调:物体传播的颜色
  • 饱和度:颜色的纯度,从0(灰)到100%(饱和)来进行描述
  • 亮度:颜色的相对明暗程度
    在Android 的 ColorMatrix 颜色矩阵中也封装了一些 API 来快速调整上面这三个颜色参数,而不用每次都去计算矩阵的值。详情可参考这个文档 :https://developer.android.com/reference/android/graphics/ColorMatrix.html

色调

Android系统提供了 setRotate(int axis, float degrees)方法来修改颜色的色调。第一个参数,用0、1、2分别代表红、绿、蓝三个颜色通道,第二个参数就是要修改的值,如下:

1
2
3
4
ColorMatrix hueMatrix = new ColorMatrix();
hueMatrix.setRotate(0,hue0);
hueMatrix.setRotate(1,hue1);
hueMatrix.setRotate(2,hue2);

Android系统的 setRotate(int axis, float degrees) 方法其实就是对色彩的旋转运算。RGB色是如何旋转的呢,首先用R、G、B三色建立三维坐标系,如下:
这里写图片描述

这里,我们把一个色彩值看成三维空间里的一个点,色彩值的三个分量可以看成该点对应的坐标(三维坐标)。先不考虑在三个维度综合情况下是怎么旋转的,我们先看看在某个轴做为Z轴,在另两个轴形成的平面上旋转的情况。假如,我们现在需要围绕蓝色轴进行旋转,我们对着蓝色箭头观察由红色和绿色构造的平面。然后顺时针旋转 α 度。 如下图所示:
这里写图片描述

在图中,我们可以看到,在旋转后,原 R 在 R 轴的分量变为:Rcosα,且原G分量在旋转后在 R 轴上也有了分量,所以我们要加上这部分分量,因此最终的结果为 R’=Rcosα + Gsinα,同理,在计算 G’ 时,因为 R 的分量落在了负轴上,所以我们要减去这部分,故 G’=Gcosα - R*sinα;
回忆之前讲过的矩阵乘法运算法则,下图:

1
2
3
4
R1 = aR + bG + cB + dA + e;
G1 = fR + gG + hB + iA + j;
B1 = kR + lG + mB + nA + o;
A1 = pR + qG + rB + sA + t;

可以计算出围绕蓝色分量轴顺时针旋转 α 度的颜色矩阵如下:
这里写图片描述
同理,可以得出围绕红色分量轴顺时针旋转 α 度的颜色矩阵:
这里写图片描述
围绕绿色分量轴顺时针旋转 α 度的颜色矩阵:
这里写图片描述

通过上面的分析,我们可以知道,当围绕红色分量轴进行色彩旋转时,由于当前红色分量轴的色彩是不变的,而仅利用三角函数来动态的变更绿色和蓝色的颜色值。这种改变就叫做色相调节。

当围绕红色分量轴旋转时,是对图片就行红色色相的调节;同理,当围绕蓝色分量轴旋转时,就是对图片就行蓝色色相调节;当然,当围绕绿色分量轴旋转时,就是对图片进行绿色色相的调节。

下面是Android系统对色调修改的源码,我们可以看得到,源码对第二个参数进行转换成弧度,即对红、绿、蓝三个颜色通道分别进行旋转,那我们在第二个参数中传入我们平时用的度数即可。通过对源码的阅读,我们也知道,第二个参数最终被设置的数值范围为 [-1,1] ,然后再设置到颜色矩阵中。即我们在第二个参数传入[-180,180]范围的值(一个最小周期)即可。

下面是上面理论设计到的相关系统源码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public void setRotate(int axis, float degrees) {
reset();
double radians = degrees * Math.PI / 180d;
float cosine = (float) Math.cos(radians);
float sine = (float) Math.sin(radians);
switch (axis) {
// Rotation around the red color
case 0:
mArray[6] = mArray[12] = cosine;
mArray[7] = sine;
mArray[11] = -sine;
break;
// Rotation around the green color
case 1:
mArray[0] = mArray[12] = cosine;
mArray[2] = -sine;
mArray[10] = sine;
break;
// Rotation around the blue color
case 2:
mArray[0] = mArray[6] = cosine;
mArray[1] = sine;
mArray[5] = -sine;
break;
default:
throw new RuntimeException();
}
}

饱和度

Android系统提供了 setSaturation(float sat) 方法来修改颜色的饱和度。参数 float sat:表示把当前色彩饱和度放大的倍数。取值为0表示完全无色彩,即灰度图像(黑白图像);取值为1时,表示色彩不变动;当取值大于1时,显示色彩过度饱和 如下:

1
2
ColorMatrix saturationMatrix = new ColorMatrix();
saturationMatrix.setSaturation(saturation);

同样贴出修改饱和度值的源码,通过源码我们可以看到系统是通过改变颜色矩阵中对角线上系数的比例来改变饱和度。系统相关源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
public void setSaturation(float sat) {
reset();
float[] m = mArray;
final float invSat = 1 - sat;
final float R = 0.213f * invSat;
final float G = 0.715f * invSat;
final float B = 0.072f * invSat;
m[0] = R + sat; m[1] = G; m[2] = B;
m[5] = R; m[6] = G + sat; m[7] = B;
m[10] = R; m[11] = G; m[12] = B + sat;
}

亮度

当三原色以相同比例进行混合时,就会显示出白色。Android系统正是利用这个原理对图像进行亮度的改变。如下:

1
2
ColorMatrix lumMatrix = new ColorMatrix();
lumMatrix.setScale(lum,lum,lum,1);

同样贴出修改亮度值的源码,当亮度为 0 时,图像就变成全黑了。通过对源码的阅读,我们可以知道, 系统将颜色矩阵置为初始初始颜色矩阵,再将红、绿、蓝、透明度四个分量通道对应的系数修改成我们传入的值。

1
2
3
4
5
6
7
8
9
10
11
12
public void setScale(float rScale, float gScale, float bScale,
float aScale) {
final float[] a = mArray;
for (int i = 19; i > 0; --i) {
a[i] = 0;
}
a[0] = rScale;
a[6] = gScale;
a[12] = bScale;
a[18] = aScale;
}

当然,除了单独使用上面的三种方法来进行颜色效果的处理之外,Android系统还封装了矩阵的乘法运算。它提供了 postConcat(ColorMatrix postmatrix) 方法来将矩阵的作用效果混合,从而叠加处理效果。如下:

1
2
3
4
ColorMatrix imageMatrix = new ColorMatrix();
imageMatrix.postConcat(hueMatrix);
imageMatrix.postConcat(saturationMatrix);
imageMatrix.postConcat(lumMatrix);

Android实例

下面,通过一个demo来给大家看看,修改色调、饱和度、亮度的效果。
首先我们看看效果图,如下:
这里写图片描述

这里的 demo 通过滑动三个 SeekBar 来改变不同的值,并将这些数值作用到对应色调、饱和度、亮度的颜色矩阵中,最后通过 ColorMatrix 的 postConcat() 方法来混合这三个被修改的颜色矩阵的显示效果。相关代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public static Bitmap handleImageEffect(Bitmap oriBmp, Bitmap bmp, float hue, float saturation, float lum) {
Canvas canvas = new Canvas(bmp);
Paint paint = new Paint();
ColorMatrix hueMatrix = new ColorMatrix();
hueMatrix.setRotate(0, hue);
hueMatrix.setRotate(1, hue);
hueMatrix.setRotate(2, hue);
ColorMatrix saturationMatrix = new ColorMatrix();
saturationMatrix.setSaturation(saturation);
ColorMatrix lumMatrix = new ColorMatrix();
lumMatrix.setScale(lum, lum, lum, 1);
ColorMatrix imageMatrix = new ColorMatrix();
imageMatrix.postConcat(hueMatrix);
imageMatrix.postConcat(saturationMatrix);
imageMatrix.postConcat(lumMatrix);
paint.setColorFilter(new ColorMatrixColorFilter(imageMatrix));
canvas.drawBitmap(oriBmp, 0, 0, paint);
return bmp;
}

Android系统不允许直接修改原图,类似 Photoshop 中的锁定,必须通过原图创建一个同样大小的 Bitmap ,并将原图绘制到该 Bitmap 中,以一个副本的形式来修改图像。
在设置好需要处理的颜色矩阵后,通过使用 Paint 类的 setColorFilter() 方法,将通过 imageMatrix 构造的 ColorMatrixColorFilter 对象传递进去,并使用这个画笔来绘制原来的图像,从而将颜色矩阵作用到图像中。
下面是布局文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="com.example.deeson.mycolor.MainActivity"
android:orientation="vertical">
<ImageView
android:id="@+id/iv_photo"
android:layout_width="300dp"
android:layout_height="300dp"
android:layout_gravity="center_horizontal"
android:layout_marginTop="20dp"
android:src="@drawable/iv_model0"/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:layout_marginLeft="5dp"
android:textColor="@android:color/black"
android:text="色调"
/>
<SeekBar
android:id="@+id/seekbarHue"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:max="200"
android:progress="100"/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:layout_marginLeft="5dp"
android:textColor="@android:color/black"
android:text="饱和度"
/>
<SeekBar
android:id="@+id/seekbarSaturation"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:max="200"
android:progress="100"/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:layout_marginLeft="5dp"
android:textColor="@android:color/black"
android:text="亮度"
/>
<SeekBar
android:id="@+id/seekbarLum"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:max="200"
android:progress="100"/>
</LinearLayout>

然后是 MainActivity 类的代码,就是获取三个 SeekBar 的值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
public class MainActivity extends AppCompatActivity implements SeekBar.OnSeekBarChangeListener {
ImageView iv_photo;
float mHue = 0.0f;
float mSaturation = 1f;
float mLum = 1f;
float MID_VALUE;
Bitmap oriBitmap,newBitmap;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
iv_photo = (ImageView) findViewById(R.id.iv_photo);
SeekBar barHue = (SeekBar) findViewById(R.id.seekbarHue);
SeekBar barSaturation = (SeekBar) findViewById(R.id.seekbarSaturation);
SeekBar barLum = (SeekBar) findViewById(R.id.seekbarLum);
MID_VALUE = barHue.getMax() * 1.0F / 2;
oriBitmap = BitmapFactory.decodeResource(getResources(), R.drawable.iv_model0);
//Android系统不允许直接修改原图
newBitmap = Bitmap.createBitmap(oriBitmap.getWidth(), oriBitmap.getHeight(), Bitmap.Config.ARGB_8888);
barHue.setOnSeekBarChangeListener(this);
barSaturation.setOnSeekBarChangeListener(this);
barLum.setOnSeekBarChangeListener(this);
}
@Override
public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
switch (seekBar.getId()) {
case R.id.seekbarHue:
mHue = (progress - MID_VALUE) * 1.0F / MID_VALUE * 180;
break;
case R.id.seekbarSaturation:
mSaturation = progress * 1.0F / MID_VALUE;
break;
case R.id.seekbarLum:
mLum = progress * 1.0F / MID_VALUE;
break;
}
iv_photo.setImageBitmap(ImageHelper.handleImageEffect(oriBitmap,newBitmap, mHue, mSaturation, mLum));
}
@Override
public void onStartTrackingTouch(SeekBar seekBar) {
}
@Override
public void onStopTrackingTouch(SeekBar seekBar) {
}
}

代码Demo
其实讲到这里,大家对颜色矩阵和滤镜的实现原理有一个大概的了解了吧。

常用颜色矩阵

灰度效果

这里写图片描述

图像反转

这里写图片描述

效果如下:
这里写图片描述

怀旧效果

这里写图片描述
效果如下:
这里写图片描述

去色效果

这里写图片描述
效果如下:

这里写图片描述

高饱和度

这里写图片描述
效果如下:
这里写图片描述

色彩反色

这里是红绿反色,另外红蓝、蓝绿反色原理一样,就是把颜色初始矩阵中对应颜色通道的值交换处理,如下:
这里写图片描述

GPUImage滤镜

GPUImage是一个专门做滤镜和帖纸的开源库,详细资料就不介绍了,给大家提供一个我开源的使用例子。
这里写图片描述

项目源码:https://github.com/xiangzhihong/gpuImage

支付宝打赏 微信打赏

如果文章对你有帮助,欢迎点击上方按钮打赏作者

上一篇