Android DIY抠图——想怎么抠就怎么抠
下面我分享Android的一个抠图技巧,这篇文章只适合有Android基础和向量基础的小伙伴,如果朋友们刚学Android不久,建议先去了解Android自定义View、Touch机制、Canvas/Path/Paint、向量等相关知识。
先来看看效果图:这张是原图
抠出脸部图片,下面是结果:
下面我给小伙伴们讲述具体流程。
1,获取本地相册,很简单,直接代码,不解释
public void scanAlbum(Context context, AlbumScanListener listener){
try {
if(listener == null){
return;
}
List<LDAPhoto> photos = new ArrayList<>();
String[] projection = {MediaStore.Images.ImageColumns.DATA};
Cursor cursor = context.getContentResolver().query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, projection, null, null,null);
while(cursor.moveToNext()) {
LDAPhoto photo = new LDAPhoto();
String path = cursor.getString(cursor.getColumnIndex(MediaStore.Images.Media.DATA));
File file = new File(path);
if(FileUtil.isFileExisted(path)) {
photo.setPath(path);
photo.setCreateTime(file.lastModified());
photos.add(photo);
}
}
listener.onCompleted(photos);
}catch (Exception e){
listener.onScannerFailed();
}
}
2,全屏预览图片,手势层与图片层分离
整个view分图片层、带透明度的手势层(Mask),mask层覆盖图片层,手势相关逻辑都在mask层展开
简而言之,就是预览图片,上面铺一层蒙层,然后在蒙层里进行相关的抠图操作,预览的时候建议不是直接加载原图,而是根据屏幕宽高对图片进行自适应缩放,比如原图可能10000x10000,而屏幕只有100x100,那直接加载原图直接oom了,可以参考我的另一篇博客,如何巧妙的读取本地图片——bitmap的常用小技巧
3手势层监听手指轨迹,生成对应闭合区域(Path)
核心思想:定义mCanvas,操作bitmap,通过onDraw,中回调的canvas绘制到屏幕。
(1)初始化画笔:
mMaskPaint是蒙层画笔,mPathPaint是手指轨迹画笔,mCirclePaint是圆滑起点和终点的画笔(手指轨迹可能不是闭合,此时应该对两端点进行圆滑处理,mPath即用户手指轨迹)
令注:mStartX,mStartY是图片的左上角坐标,mBmWidth,mBmHeight是图片宽高
private void initPaint() {
mMaskPaint = new Paint();//mask画笔
mMaskPaint.setAntiAlias(true);
mMaskPaint.setStyle(Paint.Style.FILL);
mMaskPaint.setStrokeCap(Paint.Cap.ROUND);
mMaskPaint.setStrokeJoin(Paint.Join.ROUND);
mMaskPaint.setColor(Color.parseColor("#d9000000"));
mPathPaint = new Paint();//path画笔
mPathPaint.setAlpha(0);
mPathPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DST_IN));
mPathPaint.setPathEffect(new CornerPathEffect(mPathWidth / 2));
mPathPaint.setAntiAlias(true);
mPathPaint.setDither(true);
mPathPaint.setStyle(Paint.Style.STROKE);
mPathPaint.setStrokeJoin(Paint.Join.ROUND);
mPathPaint.setStrokeWidth(mPathWidth);
mCirclePaint = new Paint();//圆滑两端的圆形画笔
mCirclePaint.setAlpha(0);
mCirclePaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DST_IN));
mCirclePaint.setPathEffect(new CornerPathEffect(mPathWidth / 2));
mCirclePaint.setAntiAlias(true);
mCirclePaint.setDither(true);
mCirclePaint.setStyle(Paint.Style.FILL);
mPath = new Path();//轨迹
mPathRect = new RectF();//轨迹所在的矩形区域
}
(2)初始化onDraw,第一次调用onDraw操作
if(mBitmap == null){
mBitmap = Bitmap.createBitmap(getWidth(), getHeight(), Bitmap.Config.ARGB_8888);
mCanvas = new Canvas(mBitmap);
}
if(mIsFirstDrawMask) {
mIsFirstDrawMask = false;
mCanvas.drawRect(0, 0, getWidth(), getHeight(), mMaskPaint);
}
(3)通过onTouch事件监听轨迹——非常重要
关键概述:记录用户手指按下、移动、抬起过程中的轨迹,如果在移动过程中轨迹出现闭合,则终止记录,抠出闭合区域,如果抬起是恰好闭合曲线,则抠出闭合区域,如果抬起时轨迹不闭合,则自动闭合,并抠出闭合区域。判断轨迹是否闭合,需要通过向量叉乘的知识,同时需要计算闭合轨迹的面积,如果面积过小(小于100像素),则不扣图,提示“请在图片上画出封闭区域”,
@Override
public boolean onTouchEvent(MotionEvent event) {
int x = (int) event.getX();
int y = (int) event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
touchStart(x, y);
break;
case MotionEvent.ACTION_MOVE:
touchMove(x, y);
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL://用户的抬手操作也可能出发的是cancel
touchUp(x, y);
break;
}
return true;
}
private void touchStart(int x, int y) {
if(mIsPathClose){//曲线已经闭合
return;
}
mPath.reset();
if(mPoints == null){
mPoints = new ArrayList<>();
}
mPoints.clear();
mPathPaint.setStyle(Paint.Style.STROKE);//设置画线
mPath.moveTo(x, y);
mCanvas.drawPath(mPath, mPathPaint);
mCanvas.drawCircle(x, y, mPathWidth / 2, mCirclePaint);//起点边缘圆滑
LDAPoint point = new LDAPoint(x, y);
mPoints.add(point);
mLastPoint = point;
setPathRect(point);
invalidate();
mHadStarted = true;
}
private void touchMove(int x, int y) {
if(!mHadStarted){
//ACTION_DOWN可能在屏幕外触发
touchStart(x, y);
return;
}
if(mIsPathClose){
return;
}
if(mLastPoint != null){
//点距超过10才记录, 防止记录的点过多
if(!isValidDistance(x, y)){
return;
}
}
//贝塞尔曲线
mPath.quadTo(mLastPoint.x / 2 + x / 2, mLastPoint.y / 2 + y / 2, x, y);
LDAPoint point = new LDAPoint(x, y);
mPoints.add(point);//记录点
//检测是否闭合
int pos = checkPathClose();
//实时更新path所在矩形
updatePathRect(point);
mLastPoint = point;
if(pos >= 0){//已经闭合
mIsPathClose = true;
getClosePath(pos + 1);
mPath.close();
mPathPaint.setStyle(Paint.Style.FILL);
drawMask();
mCanvas.drawPath(mPath, mPathPaint);
mCanvas.drawCircle(x, y, mPathWidth / 2, mCirclePaint);//端点圆滑
}else {
mCanvas.drawPath(mPath, mPathPaint);
mCanvas.drawCircle(x, y, mPathWidth / 2, mCirclePaint);
}
invalidate();
}
private void touchUp(int x, int y) {
mHadStarted = false;
//moving过程已经闭合
if(mIsPathClose){ return; }
mPathPaint.setStyle(Paint.Style.FILL);//设置填充
mPath.quadTo(mLastPoint.x / 2 + x / 2, mLastPoint.y / 2 + y / 2, x, y);
LDAPoint point = new LDAPoint(x, y);
mPoints.add(point);
updatePathRect(point);
mPath.close();//直接闭合
Region region = new Region();
Rect rect = new Rect();
rect.left = (int) mPathRect.left;
rect.right = (int) mPathRect.right;
rect.bottom = (int) mPathRect.bottom;
rect.top = (int) mPathRect.top;
region.setPath(mPath, new Region(rect));
if(calculateArea(region) < 100){//面积判断,小于100则提示
Toast.makeText(getContext(), "请在图片上画出封闭区域", Toast.LENGTH_SHORT);
reset();
invalidate();
return;
}
mIsPathClose = true;
mCanvas.drawPath(mPath, mPathPaint);
mCanvas.drawCircle(x, y, mPathWidth / 2, mCirclePaint);//端点圆滑
invalidate();
}
//检查path是否已经闭合,先通过矩形相交排斥的方法进行判断,在通过向量叉乘的方法进行判断
private int checkPathClose() {
int size = mPoints.size() - 3;
LDAPoint q2 = mPoints.get(mPoints.size() - 1);
LDAPoint q1 = mPoints.get(mPoints.size() - 2);
for(int i = 0; i < size; i++){
LDAPoint p1 = mPoints.get(i);
LDAPoint p2 = mPoints.get(i + 1);
if(!isScopeIntersect(p1, p2, q1, q2)){
continue;
}
float d1 = crossProduct(p1, q2, q1);
float d2 = crossProduct(q2, p2, q1);
float d3 = crossProduct(q1, p2, p1);
float d4 = crossProduct(p2, q2, p1);
//d1 * d2 >= 0 说明p1p2线段和直线q1q2有交点
//d3 * d4 >= 0 说明q1q2线段和直线p1p2有交点
//两者同时满足则相交
if(d1 * d2 >= 0 && d3 * d4 >= 0){
return i;
}
}
return -1;
}
//矩形相交排斥,p1 p2作为对角线的矩形和q1q2作为对角线的矩形没有相交
public boolean isScopeIntersect(LDAPoint p1, LDAPoint p2, LDAPoint q1, LDAPoint q2){
float maxQx = q2.x > q1.x ? q2.x : q1.x;
float minQx = q1.x < q2.x ? q1.x : q2.x;
if(p1.x < minQx && p2.x < minQx){
return false;
}
if(p1.x > maxQx && p2.x > maxQx){
return false;
}
float maxQy = q2.y > q1.y ? q2.y : q1.y;
float minQy = q1.y < q2.y ? q1.y : q2.y;
if(p1.y < minQy && p2.y < minQy){
return false;
}
if(p1.y > maxQy && p2.y > maxQy){
return false;
}
return true;
}
//向量差乘
private float crossProduct(LDAPoint p1, LDAPoint q2, LDAPoint q1) {
LDAPoint p = new LDAPoint();
p.x = p1.x - q1.x;
p.y = p1.y - q1.y;
LDAPoint q = new LDAPoint();
q.x = q2.x - q1.x;
q.y = q2.y - q1.y;
return p.x * q.y- q.x * p.y;
}
/**
* 计算闭合区域面积
* @param region
* @return
*/
private float calculateArea(Region region) {
RegionIterator regionIterator = new RegionIterator(region);
float area = 0;
Rect tmpRect= new Rect();
//通过计算矩形数量来获取面积
while (regionIterator.next(tmpRect)) {
area += tmpRect.width() * tmpRect.height();
}
return area;
}
4绘制轨迹——非常重要
通过回调onDraw函数进行绘制得到的轨迹,绘制之前需要对轨迹进行插值,因为如果用户手指滑动很快,得到的轨迹的点很少,导致最后得到的轨迹棱角非常明显,所以需要对轨迹进行插值,具体方法后面详述。
下面是绘制代码
if(mIsPathClose){
mBitmap = Bitmap.createBitmap(getWidth(), getHeight(), Bitmap.Config.ARGB_8888);
mCanvas = new Canvas(mBitmap);
canvas.drawBitmap(mBitmap, 0, 0, mMaskPaint);
mCanvas.drawRect(0, 0, getWidth(), getHeight(), mMaskPaint);
//向量角度插值
interploatePointsWithVertor();
if(Build.VERSION.SDK_INT >= 19) {
fixPath();//直接修正path
mCanvas.drawPath(mPath, mPathPaint);
}else{//通过四周填充矩形来修正path
mCanvas.drawPath(mPath, mPathPaint);
fixDrawOutside();
}
if(mPathListener != null){
mPathListener.onPathClosed(mPath, mPathRect);
}
mPathPaint.setStrokeWidth(mPathWidth);
}
完整的onDraw函数:
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
//初始化绘制--------------------------------------------
if(mBitmap == null){
mBitmap = Bitmap.createBitmap(getWidth(), getHeight(), Bitmap.Config.ARGB_8888);
mCanvas = new Canvas(mBitmap);
}
if(mIsFirstDrawMask) {
mIsFirstDrawMask = false;
mCanvas.drawRect(0, 0, getWidth(), getHeight(), mMaskPaint);
}
//闭合轨迹绘制------------------
if(mIsPathClose){
mBitmap = Bitmap.createBitmap(getWidth(), getHeight(), Bitmap.Config.ARGB_8888);
mCanvas = new Canvas(mBitmap);
canvas.drawBitmap(mBitmap, 0, 0, mMaskPaint);
mCanvas.drawRect(0, 0, getWidth(), getHeight(), mMaskPaint);
//向量角度插值
interploatePointsWithVertor();
if(Build.VERSION.SDK_INT >= 19) {
fixPath();//直接修正path
mCanvas.drawPath(mPath, mPathPaint);
}else{//通过四周填充矩形来修正path
mCanvas.drawPath(mPath, mPathPaint);
fixDrawOutside();
}
if(mPathListener != null){
mPathListener.onPathClosed(mPath, mPathRect);
}
mPathPaint.setStrokeWidth(mPathWidth);
}else{
canvas.drawBitmap(mBitmap, 0, 0, mMaskPaint);
}
}
(1)轨迹插值——让曲线圆滑
向量插值:当角度< 135,则进行插值C1,C2,如图,AOB为原轨迹的三个点,通过向量计算角度小于135,则取OA,OB终点C1,C2插值,最后得到的是AC1C2B四个点,从而使棱角变得圆滑,如果有必要,可以进行二度插值,比如AC1C2角度小于135,则可以在C1A和C1C2终点插值。
下面是插值代码:
private void interploatePointsWithVertor() {
for(int j = 0; j < 2; j++) {//二度插值
int size = mPoints.size();
List<LDAPoint> list = new ArrayList<>();
for (int i = 0; i < mPoints.size(); i++) {
LDAPoint O = mPoints.get(i % size);
LDAPoint A, B;
if (i != 0) {
A = mPoints.get((i - 1) % size);
} else {
A = mPoints.get(size - 1);
}
if (i == size - 1) {
B = mPoints.get(0);
} else {
B = mPoints.get(i + 1);
}
double angel = calculateAngle(A, O, B);//计算夹角
if (angel < LIMIT_ANGLE) {//插入中值
LDAPoint cOA = calculateCenterPoint(O, A);
LDAPoint cOB = calculateCenterPoint(O, B);
list.add(cOA);
list.add(cOB);
} else {
list.add(O);
}
}
mPoints = list;
}
getClosePath();//更新path
}
//计算OA向量和OB向量夹角
public double calculateAngle(LDAPoint A, LDAPoint O, LDAPoint B){
LDAPoint OA = getVector(O, A);
LDAPoint OB = getVector(O, B);
double LOA = getVectorLength(OA);
double LOB = getVectorLength(OB);
double product = getVectorProduct(OA, OB);
double cos = product / (LOA * LOB);
double angle = Math.acos(cos) * (180 / Math.PI);
return angle;
}
//向量点乘
private double getVectorProduct(LDAPoint oa, LDAPoint ob) {
return oa.x * ob.x + oa.y * ob.y;
}
//向量长度
private double getVectorLength(LDAPoint v) {
return Math.sqrt(v.x * v.x + v.y * v.y);
}
//通过两点获取向量
private LDAPoint getVector(LDAPoint start, LDAPoint end) {
return new LDAPoint(end.x - start.x, end.y - start.y);
}
(2)去除超出图片的闭合区域
如下图,黄色圈圈是手指轨迹,具有非常多的不确定性,对于非法区域则需要做出非法提示,对于局部理想区域则需要抠出包含图片的区域,超出的区域则需要舍弃掉。
if(Build.VERSION.SDK_INT >= 19) {
fixPath();//直接修正path
mCanvas.drawPath(mPath, mPathPaint);
}else{//通过四周填充矩形来修正path
mCanvas.drawPath(mPath, mPathPaint);
fixDrawOutside();
}
要摒弃非法区域和超出区域,其实只需要将轨迹和图片区域取交集即可,path取交集操作有API限制,当API大于19的手机,可以采用此方法,代码如***意,mStartX,mStartY是图片的左上角坐标,mBmWidth,mBmHeight是图片宽高
@TargetApi(Build.VERSION_CODES.KITKAT)
private void fixPath() {
fixRect();
Path path = new Path();
path.moveTo(mPathRect.left, mPathRect.top);
path.lineTo(mPathRect.right, mPathRect.top);
path.lineTo(mPathRect.right, mPathRect.bottom);
path.lineTo(mPathRect.left, mPathRect.bottom);
path.close();
mPath.op(path, Path.Op.INTERSECT);
}
private void fixRect() {
if(mPathRect.left < mStartX){
mPathRect.left = mStartX;
}
if(mPathRect.top < mStartY){
mPathRect.top = mStartY;
}
if(mPathRect.right > mStartX + mBmWidth){
mPathRect.right = mStartX + mBmWidth;
}
if(mPathRect.bottom > mStartY + mBmHeight){
mPathRect.bottom = mStartY + mBmHeight;
}
}
当Api小于19的时候,采用四周填充法:
上面是最极端的用户轨迹,方法是,先画出轨迹,然后四周填充蒙层:
//四周填充法
private void fixDrawOutside() {
if(mPathRect.left < mStartX){
Rect rect = new Rect();
rect.right = mStartX;
rect.bottom = getHeight();
rect.top = 0;
rect.left = 0;
mCanvas.drawRect(rect, mMaskPaint);
}
if(mPathRect.right > mStartX + mBmWidth){
Rect rect = new Rect();
rect.right = getWidth();
rect.bottom = getHeight();
rect.top = 0;
rect.left = mStartX + mBmWidth;
mCanvas.drawRect(rect, mMaskPaint);
}
if(mPathRect.top < mStartY){
Rect rect = new Rect();
rect.right = getWidth();
rect.bottom = mStartY;
rect.top = 0;
rect.left = 0;
mCanvas.drawRect(rect, mMaskPaint);
}
if(mPathRect.bottom > mStartY + mBmHeight){
Rect rect = new Rect();
rect.right = getWidth();
rect.bottom = getHeight();
rect.top = mStartY + mBmHeight;
rect.left = 0;
mCanvas.drawRect(rect, mMaskPaint);
}
}
5,得到抠图——非常重要
以上的操作都是为了正常的预览和得到正确的轨迹,下面代码是真正从图片中把指定区域抠出来
private void saveBitmap(Path mPath, RectF pathRect) {
Bitmap bm = Bitmap.createBitmap(getWidth(), getHeight(), Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(bm);
Paint paint = new Paint();
paint.setAntiAlias(true);
int saveCount = canvas.saveLayer(0, 0, getWidth(), getHeight(), null, Canvas.ALL_SAVE_FLAG);
paint.setStyle(Paint.Style.FILL);
canvas.drawPath(mPath, paint);
//path bitmap取交集
paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN));
canvas.drawBitmap(mBgBitmap, mStartX, mStartY, paint);
paint.setXfermode(null);
canvas.save(Canvas.ALL_SAVE_FLAG );
canvas.restoreToCount(saveCount);
saveBitmapToLocal(bm, pathRect);//保存bitmap到本地,普通的文件流即可
}
下面是成果展示:
手指移动过程:
手指抬起:
得到图片:
过程比较复杂,但自认为文章写的很仔细,一个流程看下来,必然了然于胸,有疑问的小朋友,欢迎评论