一、前期基础知识储备
UI掌握PS这一逆天的软件,可以实现将图片转化为素描或者水彩的效果,以素描为例:
- 在Photoshop中打开一张人物照片,按下快捷键“Ctrl+Shift+U”,把它转换成黑白颜色;
- 复制图层,得到一个副本图层。按下快捷键“Ctrl+I”,将副本图层转换成负片效果;
- 将副本图层下拉菜单选为“颜色减淡”,这时图片会亮得几乎什么也看不见,不要急,慢慢来;
- 在“滤镜”菜单下选择“模糊→高斯模糊”,模糊半径值可根据你需要的素描线条粗细深浅来设置。到此素描画像工作就完成了。
我们将以上四步进行抽象,得到将图片转为素描效果的步骤即为:
- 去色,将图片变为灰度图,即黑白图;
- 反相,得到每个像素的补色,具体效果就像照片的底片;
- 高斯模糊,把反相后的像素值平均一下;
- 颜色减淡,将第1步中的像素和第3步得到的像素值进行计算。
在Android图像处理领域,我们可以使用像素点分析的方法实现上述的效果组合。下面,用代码实现上述过程
二、上代码,具体实现素描算法
1)去色,获取黑白图;
public static int[] discolor(Bitmap bitmap) {
int picHeight = bitmap.getHeight();
int picWidth = bitmap.getWidth();
int[] pixels = new int[picWidth * picHeight];
bitmap.getPixels(pixels, 0, picWidth, 0, 0, picWidth, picHeight);
for (int i = 0; i < picHeight; ++i) {
for (int j = 0; j < picWidth; ++j) {
int index = i * picWidth + j;
int color = pixels[index];
int r = (color & 0x00ff0000) >> 16;
int g = (color & 0x0000ff00) >> 8;
int b = (color & 0x000000ff);
int grey = (int) (r * KR + g * KG + b * KB);
pixels[index] = grey << 16 | grey << 8 | grey | 0xff000000;
}
}
return pixels;
}
2)反相,得到图片的底图;
public static int[] reverseColor(int[] pixels) {
int length = pixels.length;
int[] result = new int[length];
for (int i = 0; i < length; ++i) {
int color = pixels[i];
int r = 255 - (color & 0x00ff0000) >> 16;
int g = 255 - (color & 0x0000ff00) >> 8;
int b = 255 - (color & 0x000000ff);
result[i] = r << 16 | g << 8 | b | 0xff000000;
}
return result;
}
3)高斯模糊,得到反高斯图像;
public static void gaussBlur(int[] data, int width, int height, int radius,
float sigma) {
float pa = (float) (1 / (Math.sqrt(2 * Math.PI) * sigma));
float pb = -1.0f / (2 * sigma * sigma);
// generate the Gauss Matrix
float[] gaussMatrix = new float[radius * 2 + 1];
float gaussSum = 0f;
for (int i = 0, x = -radius; x <= radius; ++x, ++i) {
float g = (float) (pa * Math.exp(pb * x * x));
gaussMatrix[i] = g;
gaussSum += g;
}
for (int i = 0, length = gaussMatrix.length; i < length; ++i) {
gaussMatrix[i] /= gaussSum;
}
// x direction
for (int y = 0; y < height; ++y) {
for (int x = 0; x < width; ++x) {
float r = 0, g = 0, b = 0;
gaussSum = 0;
for (int j = -radius; j <= radius; ++j) {
int k = x + j;
if (k >= 0 && k < width) {
int index = y * width + k;
int color = data[index];
int cr = (color & 0x00ff0000) >> 16;
int cg = (color & 0x0000ff00) >> 8;
int cb = (color & 0x000000ff);
r += cr * gaussMatrix[j + radius];
g += cg * gaussMatrix[j + radius];
b += cb * gaussMatrix[j + radius];
gaussSum += gaussMatrix[j + radius];
}
}
int index = y * width + x;
int cr = (int) (r / gaussSum);
int cg = (int) (g / gaussSum);
int cb = (int) (b / gaussSum);
data[index] = cr << 16 | cg << 8 | cb | 0xff000000;
}
}
// y direction
for (int x = 0; x < width; ++x) {
for (int y = 0; y < height; ++y) {
float r = 0, g = 0, b = 0;
gaussSum = 0;
for (int j = -radius; j <= radius; ++j) {
int k = y + j;
if (k >= 0 && k < height) {
int index = k * width + x;
int color = data[index];
int cr = (color & 0x00ff0000) >> 16;
int cg = (color & 0x0000ff00) >> 8;
int cb = (color & 0x000000ff);
r += cr * gaussMatrix[j + radius];
g += cg * gaussMatrix[j + radius];
b += cb * gaussMatrix[j + radius];
gaussSum += gaussMatrix[j + radius];
}
}
int index = y * width + x;
int cr = (int) (r / gaussSum);
int cg = (int) (g / gaussSum);
int cb = (int) (b / gaussSum);
data[index] = cr << 16 | cg << 8 | cb | 0xff000000;
}
}
}
4)淡化颜色,生成Sketch图
public static void colorDodge(int[] baseColor, int[] mixColor) {
for (int i = 0, length = baseColor.length; i < length; ++i) {
int bColor = baseColor[i];
int br = (bColor & 0x00ff0000) >> 16;
int bg = (bColor & 0x0000ff00) >> 8;
int bb = (bColor & 0x000000ff);
int mColor = mixColor[i];
int mr = (mColor & 0x00ff0000) >> 16;
int mg = (mColor & 0x0000ff00) >> 8;
int mb = (mColor & 0x000000ff);
int nr = colorDodgeFormular(br, mr);
int ng = colorDodgeFormular(bg, mg);
int nb = colorDodgeFormular(bb, mb);
baseColor[i] = nr << 16 | ng << 8 | nb | 0xff000000;
}
}
private static int colorDodgeFormular(int base, int mix) {
int result = base + (base * mix) / (255 - mix);
result = result > 255 ? 255 : result;
return result;
}
5)将上述代码封如工具类中,最后定义一个获取素描图的方法
public static Bitmap testGaussBlur(Bitmap src, int r, int fai) {
int width = src.getWidth();
int height = src.getHeight();
int[] pixels = Sketch.discolor(src);
int[] copixels = Sketch.simpleReverseColor(pixels);
Sketch.simpleGaussBlur(copixels, width, height, r, fai);
Sketch.simpleColorDodge(pixels, copixels);
Bitmap bitmap = Bitmap.createBitmap(pixels, width, height,
Config.RGB_565);
return bitmap;
}
外界,调用时,传入两个int类型的参数即可,表示高斯模糊的程度和半径。
public class SketchActivity extends AppCompatActivity {
private static final String TAG = "SketchActivity";
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_sketch);
DisplayMetrics displayMetrics = new DisplayMetrics();
getWindowManager().getDefaultDisplay().getMetrics(displayMetrics);
int width = displayMetrics.widthPixels;
int height = displayMetrics.heightPixels;
ImageView imageView = findViewById(R.id.test_img);
Bitmap bitmap = BitmapFactory.decodeResource(getResources(),R.drawable.timg);
// 对大图进行压缩 传入的参数为手机屏幕的尺寸 工具类在文末给出
bitmap = Utils.compressBySampleSize(bitmap, width, height, false);
Bitmap bitmap = SketchUtil.testGaussBlur(bitmap,10,10);
imageView.setImageBitmap(bitmap); // 素描图
}
@Override
protected void onDestroy() {
super.onDestroy();
}
}
效果如下:
三、上代码,具体实现手指在图片上左右滑动调节图片透明度,实现类似调节素描浓度的效果
在上面的算法中,我们为testGaussBlur()传入两个参数,即可实现调节素描的效果。现在提出另一个思路,就是在不改变已传入参数的前提下,调节素描的浓度:
要求:手指左滑浓度变淡,即素描图变透明;手指右滑变浓,即凸显素描图。
① 我们在原有ImageView的位置放入一个FrameLayout,然后在这个FrameLayout中放入两个ImageView,一个用于放置原图,在底层;一个用于放置素描效果图,在上层。两个ImageView的大小设为一样大,由于FrameLayout的特性,我们只能看见效果图。
② 然后我们为上层放置效果图的ImageView写入手势控制事件,当用户在图片上左右移动手指的时候,就调节上层图片的透明度,这样就达到了类似调节素描浓度的效果。
实现如下:
/**
* 作者 cpf
* 时间 2019/4/16
* 文件 TestApplication
* 描述 手指在图片上滑动 可调节图片的透明度
* ①使用Seekbar是否可以做到? 没有办法区分左右滑动 记录上次滑动结果可以做的到 在onStopTrackingTouch方法中记录
* ②自定义View + 手势移动 onFling没法表示中间的过程 只有起始和结束时的状态 ,需要一个渐变的距离 onScroll
*/
public class SeekbarActivity extends AppCompatActivity implements View.OnTouchListener {
private static final String TAG = "SeekbarActivity";
private ImageView mImageView, mGestureImageView, originImageView;
private SeekBar mSeekBar;
private TextView mTxt, mGesTxt;
private int mProgress = 100;
private float mDistance = 1, maxDistance = 100; // 控制progress参数在0-100,0为完全透明 100为起始值可见
private GestureDetector mGestureDetector;
private Bitmap finalBitmap,bitmap;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_seekbar);
DisplayMetrics displayMetrics = new DisplayMetrics();
getWindowManager().getDefaultDisplay().getMetrics(displayMetrics);
int width = displayMetrics.widthPixels;
int height = displayMetrics.heightPixels;
mImageView = findViewById(R.id.alpha_img);
mGestureImageView = findViewById(R.id.ges_alpha_img);
originImageView = findViewById(R.id.origin_img);
mSeekBar = findViewById(R.id.alpha_seek);
mTxt = findViewById(R.id.alpha_txt);
mGesTxt = findViewById(R.id.ges_alpha_txt);
bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.timg);
bitmap = Utils.compressBySampleSize(bitmap, width, height, false);
finalBitmap = SketchUtil.testGaussBlur(bitmap,10,10);
mImageView.setImageBitmap(bitmap);
mGestureImageView.setImageBitmap(finalBitmap);
originImageView.setImageBitmap(bitmap);
mSeekBar.setMax(100); // 100 代表完不全透明 0 代表完全透明
mSeekBar.setProgress(100);
mSeekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
@Override
public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
Log.d(TAG, "onProgressChanged: direction..." + direction);
if (direction == 0) {
// 左滑 变透明
if ((mProgress - progress) > 0 && (mProgress - progress) < 100) {
mImageView.setImageBitmap(SketchUtil.testGaussBlur(Utils.setAlpha(finalBitmap, (mProgress - progress)),10,10));
mTxt.setText(String.valueOf((mProgress - progress)) + "%");
Log.d(TAG, "onProgressChanged: 左滑," + (mProgress - progress));
} else if ((mProgress - progress) < 0) {
mImageView.setImageBitmap(Utils.setAlpha(SketchUtil.testGaussBlur(finalBitmap,10,10), 1));
mTxt.setText(String.valueOf(1) + "%");
Log.d(TAG, "onProgressChanged: 左滑过界," + 1);
}
} else {
// 右滑 变清晰
if ((mProgress + progress) < 100 && (mProgress + progress) > 0) {
mImageView.setImageBitmap(SketchUtil.testGaussBlur(Utils.setAlpha(finalBitmap, (mProgress + progress)),10,10));
mTxt.setText(String.valueOf((mProgress + progress)) + "%");
Log.d(TAG, "onProgressChanged: 右滑," + (mProgress + progress));
} else if ((mProgress + progress) > 100) {
mImageView.setImageBitmap(Utils.setAlpha(SketchUtil.testGaussBlur(finalBitmap,10,10), 100));
mTxt.setText(String.valueOf(100) + "%");
Log.d(TAG, "onProgressChanged: 右滑过界," + 100);
}
}
}
@Override
public void onStartTrackingTouch(SeekBar seekBar) {
}
@Override
public void onStopTrackingTouch(SeekBar seekBar) {
mTxt.setVisibility(View.GONE);
mProgress = seekBar.getProgress();
Log.d(TAG, "onProgressChanged, onStopTrackingTouch:::" + mProgress);
}
});
//初始化图片
mImageView.setImageBitmap(Utils.setAlpha(finalBitmap, 100)); // 0 代表完全透明
initGesture();
}
private void initGesture() {
mGestureDetector = new GestureDetector(new simpleGestureListener());
// 注意以下四个声明不可缺少
mGestureImageView.setOnTouchListener(this);
mGestureImageView.setFocusable(true);
mGestureImageView.setClickable(true);
mGestureImageView.setLongClickable(true);
}
@Override
public boolean onTouch(View v, MotionEvent event) {
// TODO Auto-generated method stub
return mGestureDetector.onTouchEvent(event);
}
private int direction = 0;
private class simpleGestureListener extends
GestureDetector.SimpleOnGestureListener {
/*****OnGestureListener的函数*****/
final int FLING_MIN_DISTANCE = 10;
final float MIN_DISTANCE = 0, MAX_DISTANCE = 100;
public boolean onDown(MotionEvent e) {
// do nothing
return false;
}
public boolean onScroll(MotionEvent e1, MotionEvent e2,
float distanceX, float distanceY) {
if (e1.getX() - e2.getX() > FLING_MIN_DISTANCE) {
// Fling left 左滑变透明 distanceX为正值
int progress = (int) (maxDistance - distanceX / 15);
if (progress >= MIN_DISTANCE) {
mGestureImageView.setImageBitmap(Utils.setAlpha(finalBitmap, progress));
maxDistance = progress;
mProgress = progress;
mGesTxt.setVisibility(View.VISIBLE);
mGesTxt.setText(String.valueOf(progress) + "%");
Log.d("MyGesture22", "onScroll:" + progress + ", 左滑 ," + distanceX);
}
} else if (e2.getX() - e1.getX() > FLING_MIN_DISTANCE) {
// Fling right 右滑显示 distanceX为负值
int progress = (int) (maxDistance - distanceX / 5);
if (progress <= MAX_DISTANCE) {
mGestureImageView.setImageBitmap(Utils.setAlpha(finalBitmap, progress));
maxDistance = progress;
mProgress = progress;
mGesTxt.setVisibility(View.VISIBLE);
mGesTxt.setText(String.valueOf(progress) + "%");
Log.d("MyGesture22", "onScroll:" + progress + ", 右滑 ," + distanceX);
}
}
return true;
}
// 用户按下触摸屏、快速移动后松开,由1个MotionEvent ACTION_DOWN, 多个ACTION_MOVE, 1个ACTION_UP触发
public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX,
float velocityY) {
mGesTxt.setVisibility(View.GONE);
mImageView.setImageBitmap(Utils.xFerMode(bitmap,Utils.setAlpha(finalBitmap, mProgress)));
Log.d(TAG, "onFling: " + mProgress);
return true;
}
}
}
可以看到,笔者先是尝试使用SeekBar的方式来实现类似的效果,即将该SeekBar置于图片的上层,然后重写其两个属性,将其设为完全透明,即对于用户不可见,同时将SeekBar设为大小同ImageView一样大,也就实现了对于图片的覆盖,然后在对SeekBar的滑动监听事件onProgressChanged()中调节效果图的透明度,同时在另一监听方法onStopTrackingTouch()记录手指上次离开屏幕时的数值,这样在下次手指down下的时候,传入该值即可。用SeekBar实现这一需求只能做到一半,如上面代码中的注视一般,可以做到记录上次滑动的数值,但是没有办法区分开手指滑动的方向,即无法验证手指左滑还是右滑。
而后,笔者采用GestureDetector的方式实现,拦截下屏幕上的触摸事件,并把事件设置给展示效果图的ImageView。这样手指滑动的事件就传递给了ImageView。然后在重写onScroll()方法,区分开手指左右滑动,并在对应的实现中对图片透明度做不同的调整;最后重写onFling()方法,识别手指抬起,将展示图片透明度数值的TextView设为不可见。
效果如下:
以下是文中使用到的工具类:用以加载大图、调节图片透明度。还有其他一些关于Bitmap的处理方法。
public class Utils {
public static final String TAG = "Utils:";
private static final String SAVE_FOLDER = "PencilCamera";
private static final String SAVE_FILENAME_PREFIX = "IMG";
private static int lastSaveFileIndex = 0;
/**
* 图片透明度处理
*
* @param sourceImg 原始图片
* @param number 透明度
* @return
*/
public static Bitmap setAlpha(Bitmap sourceImg, int number) {
try {
int[] argb = new int[sourceImg.getWidth() * sourceImg.getHeight()];
sourceImg.getPixels(argb, 0, sourceImg.getWidth(), 0, 0,
sourceImg.getWidth(), sourceImg.getHeight());// 获得图片的ARGB值
number = number * 255 / 100;
for (int i = 0; i < argb.length; i++) {
if ((argb[i] & 0xff000000) != 0x00000000) {// 透明色不做处理
argb[i] = (number << 24) | (argb[i] & 0xFFFFFF);// 修改最高2位的值
}
}
sourceImg = Bitmap.createBitmap(argb, sourceImg.getWidth(),
sourceImg.getHeight(), Bitmap.Config.ARGB_8888);
} catch (OutOfMemoryError e) {
e.printStackTrace();
System.gc();
}
return sourceImg;
}
/**
* 混合两张Bitmap 返回融合后的Bitmap
*
* @param src 主图
* @param dst 修饰图
*/
public static Bitmap xFerMode(Bitmap src, Bitmap dst) {
Bitmap lightenModeBitmap = Bitmap.createBitmap(dst.getWidth(), dst.getHeight(), Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(lightenModeBitmap);
Paint paint1 = new Paint();
paint1.setAntiAlias(true);
Rect srcRect = new Rect(0, 0, src.getWidth(), src.getHeight());
canvas.drawARGB(0, 0, 0, 0);
canvas.drawBitmap(src, srcRect, srcRect, paint1); //画人物 src
paint1.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.LIGHTEN));
// 再把原来的bitmap画到现在的bitmap
canvas.drawBitmap(dst, srcRect, srcRect, paint1); //画特效 dst
return lightenModeBitmap;
}
/**
* Return the compressed bitmap using sample size.
*
* @param src The source of bitmap.
* @param maxWidth The maximum width.
* @param maxHeight The maximum height.
* @param recycle True to recycle the source of bitmap, false otherwise.
* @return the compressed bitmap
*/
public static Bitmap compressBySampleSize(final Bitmap src,
final int maxWidth,
final int maxHeight,
final boolean recycle) {
if (isEmptyBitmap(src)) return null;
BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
ByteArrayOutputStream baos = new ByteArrayOutputStream();
src.compress(Bitmap.CompressFormat.JPEG, 100, baos);
byte[] bytes = baos.toByteArray();
BitmapFactory.decodeByteArray(bytes, 0, bytes.length, options);
options.inSampleSize = calculateInSampleSize(options, maxWidth, maxHeight);
options.inJustDecodeBounds = false;
if (recycle && !src.isRecycled()) src.recycle();
return BitmapFactory.decodeByteArray(bytes, 0, bytes.length, options);
}
/**
* Return the sample size.
* 1500*1500的大图采样率为8 尺寸为562*562
* 300*400的采样率为2
* @param options The options.
* @param maxWidth The maximum width.
* @param maxHeight The maximum height.
* @return the sample size
*/
private static int calculateInSampleSize(final BitmapFactory.Options options,
final int maxWidth,
final int maxHeight) {
int height = options.outHeight;
int width = options.outWidth;
int inSampleSize = 1;
while (height > maxHeight || width > maxWidth) {
height >>= 1;
width >>= 1;
inSampleSize <<= 1;
}
return inSampleSize;
}
private static boolean isEmptyBitmap(final Bitmap src) {
return src == null || src.getWidth() == 0 || src.getHeight() == 0;
}
/**
* Reize bitmap with dimensions equal to or less than given params, without changing the aspect ratio
* @param bitmap Input bitmap
* @param maxWidth Max allowed width of resized Bitmap
* @param maxHeight Max allowed height of resized Bitmap
* @return Resized bitmap
*/
public static Bitmap resizeBitmap(Bitmap bitmap, int maxWidth, int maxHeight) {
if (maxHeight > 0 && maxWidth > 0) {
int width = bitmap.getWidth();
int height = bitmap.getHeight();
float ratioBitmap = (float) width / (float) height;
float ratioMax = (float) maxWidth / (float) maxHeight;
int finalWidth = maxWidth;
int finalHeight = maxHeight;
if (ratioMax > ratioBitmap) {
finalWidth = (int) ((float)maxHeight * ratioBitmap);
} else {
finalHeight = (int) ((float)maxWidth / ratioBitmap);
}
Bitmap scaledBitmap = Bitmap.createScaledBitmap(bitmap, finalWidth, finalHeight, true);
return scaledBitmap;
} else {
return bitmap;
}
}
/**
* Save bitmap as JPEG with a incremental filename, in the SAVE_FOLDER directory
* @param activity
* @param bitmap
* @return
* @throws IOException
*/
public static String saveBitmap(Activity activity, Bitmap bitmap) throws IOException {
File file;
try {
// Create a new save file
file = createSaveFile();
OutputStream fOut = new FileOutputStream(file);
// saving the Bitmap to a file compressed as a JPEG with 85% compression rate
bitmap.compress(Bitmap.CompressFormat.JPEG, 85, fOut);
fOut.close();
// Add saved file to gallery
Intent mediaScanIntent = new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE);
Uri contentUri = Uri.fromFile(file);
mediaScanIntent.setData(contentUri);
activity.sendBroadcast(mediaScanIntent);
} catch(IOException e) {
String errorMsg = "Unable to save image";
Log.e(TAG, errorMsg + ":" + e.getMessage());
throw new IOException(errorMsg);
}
return file.getPath();
}
/**
* Rotate bitmap by given angle in degrees
* @param bitmap
* @param angle
* @return
*/
public static Bitmap rotateBitmap(Bitmap bitmap, int angle) {
Matrix matrix = new Matrix();
matrix.postRotate(angle);
return Bitmap.createBitmap(bitmap, 0, 0, bitmap.getWidth(), bitmap.getHeight(), matrix, true);
}
/**
* Create a new save file in the SAVE_FOLDER directory with name as '<SAVE_FILENAME_PREFIX>_<3 digit serial number>'
* ( example: IMG_001)
* @return
* @throws IOException
*/
private static File createSaveFile() throws IOException {
File file;
String path = Environment.getExternalStorageDirectory().toString();
OutputStream fOut = null;
String saveFolderPath = path + '/' + SAVE_FOLDER;
int saveFileIndex = lastSaveFileIndex;
do {
String saveFileName = SAVE_FILENAME_PREFIX + String.format("%03d", ++saveFileIndex) + ".jpg";
String saveFilePath = saveFolderPath + '/' + saveFileName;
Log.d(TAG,"Saving image to path - "+saveFilePath);
file = new File(saveFilePath); // the File to save to
file.getParentFile().mkdirs();
} while(file.exists());
file.createNewFile();
return file;
}
}