手记

Android:修图技术之滤镜效果实现及原理分析——ColorMatrix

色光三原色


Android对于图片的处理,最常使用到的数据结构是位图——Bitmap,它包含了一张图片所有的数据。整个图片都是由点阵和颜色值组成的,所谓点阵就是一个包含像素的矩阵,每一个元素对应着图片的一个像素。而颜色值——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来处理颜色分量矩阵,如下:

利用线性代数知识可知:

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

从这个公式可以发现,矩阵A中:

  • 第一行的 abcde 用来决定新的颜色值中的R——红色

  • 第二行的 fghij 用来决定新的颜色值中的G——绿色

  • 第三行的 klmno 用来决定新的颜色值中的B——蓝色

  • 第四行的 pqrst 用来决定新的颜色值中的A——透明度

  • 矩阵A中第五列——ejot 值分别用来决定每个分量中的 offset ,即偏移量

这样划分好后,这些值的作用就比较明确了。

初始颜色矩阵

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

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倍,这样在矩阵运算后,图像会整体色调偏绿显示。
请看下面的对比图:

左原图——右图绿色分量系数为2

通过前面的分析,我们知道调整颜色矩阵可以改变图像的色彩效果,图像的色彩处理很大程度上就是在寻找处理图像的颜色矩阵。
下面,我们着手写一个demo,模拟一个 4x5 的颜色矩阵来体验一下上面对颜色矩阵的分析。
先来看看效果图:

ColorMatrix

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

    //将矩阵设置到图像
    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布局:

<?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() 方法,在视图创建完毕后获得其宽高值。如下:

matrixLayout.post(new Runnable() {    @Override
    public void run() {
        mEtWidth = matrixLayout.getWidth() / 5;
        mEtHeight = matrixLayout.getHeight() / 4;
        addEts();
        initMatrix();
    }

});

接下来是 MainActivity 类的全部代码,也没有几行,相信大家都能看得明白,如下:

package com.example.deeson.mycolormatrix;import android.graphics.Bitmap;import android.graphics.BitmapFactory;import android.graphics.Canvas;import android.graphics.ColorMatrix;import android.graphics.ColorMatrixColorFilter;import android.graphics.Paint;import android.support.v7.app.AppCompatActivity;import android.os.Bundle;import android.text.InputType;import android.view.View;import android.widget.Button;import android.widget.EditText;import android.widget.GridLayout;import android.widget.ImageView;/**
 * @author Deeson
 */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();
    }
}

MyColorMatrixDemo 下载地址

至此,我们可以通过 MyColorMatrixDemo 来对图像的色彩特效进行精确地修改。
在了解了Android的颜色矩阵之后,我们知道可以通过修改颜色矩阵的值来改变图像的色彩显示效果。接下来,我们看看Android系统给我们提供的快速修改图像色彩特效的API。

图像的色光属性

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

  • 色调——物体传播的颜色

  • 饱和度——颜色的纯度,从0(灰)到100%(饱和)来进行描述

  • 亮度——颜色的相对明暗程度

在Android 的 ColorMatrix 颜色矩阵中也封装了一些 API 来快速调整上面这三个颜色参数,而不用每次都去计算矩阵的值。

下面我们来看看如何调用这些API:
详情可参考这个文档 https://developer.android.com/reference/android/graphics/ColorMatrix.html

  • 色调

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

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三色建立三维坐标系,如下:

RBG坐标

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

围绕蓝色轴进行旋转 α 度

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

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]范围的值(一个最小周期)即可。
另外,相信大家都可以看得出来,系统对存储颜色矩阵的一维数组元素的值的修改正好符合上面的分析。

/**
     * Set the rotation on a color axis by the specified values.
     * <p>
     * <code>axis=0</code> correspond to a rotation around the RED color
     * <code>axis=1</code> correspond to a rotation around the GREEN color
     * <code>axis=2</code> correspond to a rotation around the BLUE color
     * </p>
     */
    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时,显示色彩过度饱和 如下:

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

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

/**
     * Set the matrix to affect the saturation of colors.
     *
     * @param sat A value of 0 maps the color to gray-scale. 1 is identity.
     */
    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系统正是利用这个原理对图像进行亮度的改变。如下:

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

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

    /**
     * Set this colormatrix to scale by the specified values.
     */
    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) 方法来将矩阵的作用效果混合,从而叠加处理效果。如下:

ColorMatrix imageMatrix = new ColorMatrix();
imageMatrix.postConcat(hueMatrix);
imageMatrix.postConcat(saturationMatrix);
imageMatrix.postConcat(lumMatrix);

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

ColorMatrix

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

    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 对象传递进去,并使用这个画笔来绘制原来的图像,从而将颜色矩阵作用到图像中。

接下来我们看看这个demo的xml代码,很简单,一个 ImageView ,三个 SeekBar ,如下:

<?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 的值,如下:

package com.example.deeson.mycolor;import android.graphics.Bitmap;import android.graphics.BitmapFactory;import android.os.Bundle;import android.support.v7.app.AppCompatActivity;import android.widget.ImageView;import android.widget.SeekBar;/**
 * @author Deeson
 */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) {

    }
}

MyColorDemo 源码下载

写到这里,我们差不多就可以自己实现一个图像美颜处理的APP。当然这只是一个整体的思路,中间还有很多的细节需要去完善。我们现在都知道改变颜色矩阵的系数就能达到一种意想不到的图片风格,比如什么xx色调,xx滤镜等等。无非是每种色调的颜色矩阵的系数不一样而已。

一些常用的图像处理效果的颜色矩阵

灰度效果

灰度效果

灰度效果

图像反转

图像反转

图像反转

怀旧效果

怀旧效果

怀旧效果

去色效果

去色效果

去色效果

高饱和度

高饱和度

高饱和度

色彩反色

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

色彩反色——红绿反色

色彩反色——红绿反色

后续

学习资料
《Android群英传》

源码下载
MyColorMatrixDemo 下载地址
MyColorDemo 源码下载



作者:涤生_Woo
链接:https://www.jianshu.com/p/9a44d04f39fc


1人推荐
随时随地看视频
慕课网APP