Home | History | Annotate | Download | only in graphics
      1 /*
      2  * Copyright (C) 2015 The Android Open Source Project
      3  *
      4  * Licensed under the Apache License, Version 2.0 (the "License");
      5  * you may not use this file except in compliance with the License.
      6  * You may obtain a copy of the License at
      7  *
      8  *      http://www.apache.org/licenses/LICENSE-2.0
      9  *
     10  * Unless required by applicable law or agreed to in writing, software
     11  * distributed under the License is distributed on an "AS IS" BASIS,
     12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
     13  * See the License for the specific language governing permissions and
     14  * limitations under the License.
     15  */
     16 
     17 package com.android.launcher3.graphics;
     18 
     19 import android.content.Context;
     20 import android.graphics.Bitmap;
     21 import android.graphics.Canvas;
     22 import android.graphics.Color;
     23 import android.graphics.Matrix;
     24 import android.graphics.Paint;
     25 import android.graphics.Path;
     26 import android.graphics.PorterDuff;
     27 import android.graphics.PorterDuffXfermode;
     28 import android.graphics.Rect;
     29 import android.graphics.RectF;
     30 import android.graphics.drawable.AdaptiveIconDrawable;
     31 import android.graphics.drawable.Drawable;
     32 import android.support.annotation.NonNull;
     33 import android.support.annotation.Nullable;
     34 import android.util.Log;
     35 import com.android.launcher3.LauncherAppState;
     36 import com.android.launcher3.Utilities;
     37 import java.io.File;
     38 import java.io.FileOutputStream;
     39 import java.nio.ByteBuffer;
     40 import java.util.Random;
     41 
     42 public class IconNormalizer {
     43 
     44     private static final String TAG = "IconNormalizer";
     45     private static final boolean DEBUG = false;
     46     // Ratio of icon visible area to full icon size for a square shaped icon
     47     private static final float MAX_SQUARE_AREA_FACTOR = 375.0f / 576;
     48     // Ratio of icon visible area to full icon size for a circular shaped icon
     49     private static final float MAX_CIRCLE_AREA_FACTOR = 380.0f / 576;
     50 
     51     private static final float CIRCLE_AREA_BY_RECT = (float) Math.PI / 4;
     52 
     53     // Slope used to calculate icon visible area to full icon size for any generic shaped icon.
     54     private static final float LINEAR_SCALE_SLOPE =
     55             (MAX_CIRCLE_AREA_FACTOR - MAX_SQUARE_AREA_FACTOR) / (1 - CIRCLE_AREA_BY_RECT);
     56 
     57     private static final int MIN_VISIBLE_ALPHA = 40;
     58 
     59     // Shape detection related constants
     60     private static final float BOUND_RATIO_MARGIN = .05f;
     61     private static final float PIXEL_DIFF_PERCENTAGE_THRESHOLD = 0.005f;
     62     private static final float SCALE_NOT_INITIALIZED = 0;
     63 
     64     private static final Object LOCK = new Object();
     65     private static IconNormalizer sIconNormalizer;
     66 
     67     private final int mMaxSize;
     68     private final Bitmap mBitmap;
     69     private final Bitmap mBitmapARGB;
     70     private final Canvas mCanvas;
     71     private final Paint mPaintMaskShape;
     72     private final Paint mPaintMaskShapeOutline;
     73     private final byte[] mPixels;
     74     private final int[] mPixelsARGB;
     75 
     76     private final Rect mAdaptiveIconBounds;
     77     private float mAdaptiveIconScale;
     78 
     79     // for each y, stores the position of the leftmost x and the rightmost x
     80     private final float[] mLeftBorder;
     81     private final float[] mRightBorder;
     82     private final Rect mBounds;
     83     private final Matrix mMatrix;
     84 
     85     private final Paint mPaintIcon;
     86     private final Canvas mCanvasARGB;
     87 
     88     private final File mDir;
     89     private int mFileId;
     90     private final Random mRandom;
     91 
     92     private IconNormalizer(Context context) {
     93         // Use twice the icon size as maximum size to avoid scaling down twice.
     94         mMaxSize = LauncherAppState.getIDP(context).iconBitmapSize * 2;
     95         mBitmap = Bitmap.createBitmap(mMaxSize, mMaxSize, Bitmap.Config.ALPHA_8);
     96         mCanvas = new Canvas(mBitmap);
     97         mPixels = new byte[mMaxSize * mMaxSize];
     98         mPixelsARGB = new int[mMaxSize * mMaxSize];
     99         mLeftBorder = new float[mMaxSize];
    100         mRightBorder = new float[mMaxSize];
    101         mBounds = new Rect();
    102         mAdaptiveIconBounds = new Rect();
    103 
    104         // Needed for isShape() method
    105         mBitmapARGB = Bitmap.createBitmap(mMaxSize, mMaxSize, Bitmap.Config.ARGB_8888);
    106         mCanvasARGB = new Canvas(mBitmapARGB);
    107 
    108         mPaintIcon = new Paint();
    109         mPaintIcon.setColor(Color.WHITE);
    110 
    111         mPaintMaskShape = new Paint();
    112         mPaintMaskShape.setColor(Color.RED);
    113         mPaintMaskShape.setStyle(Paint.Style.FILL);
    114         mPaintMaskShape.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.XOR));
    115 
    116         mPaintMaskShapeOutline = new Paint();
    117         mPaintMaskShapeOutline.setStrokeWidth(2 * context.getResources().getDisplayMetrics().density);
    118         mPaintMaskShapeOutline.setStyle(Paint.Style.STROKE);
    119         mPaintMaskShapeOutline.setColor(Color.BLACK);
    120         mPaintMaskShapeOutline.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DST_OUT));
    121 
    122         mMatrix = new Matrix();
    123         mAdaptiveIconScale = SCALE_NOT_INITIALIZED;
    124 
    125         mDir = context.getExternalFilesDir(null);
    126         mRandom = new Random();
    127     }
    128 
    129     /**
    130      * Returns if the shape of the icon is same as the path.
    131      * For this method to work, the shape path bounds should be in [0,1]x[0,1] bounds.
    132      */
    133     private boolean isShape(Path maskPath) {
    134         // Condition1:
    135         // If width and height of the path not close to a square, then the icon shape is
    136         // not same as the mask shape.
    137         float iconRatio = ((float) mBounds.width()) / mBounds.height();
    138         if (Math.abs(iconRatio - 1) > BOUND_RATIO_MARGIN) {
    139             if (DEBUG) {
    140                 Log.d(TAG, "Not same as mask shape because width != height. " + iconRatio);
    141             }
    142             return false;
    143         }
    144 
    145         // Condition 2:
    146         // Actual icon (white) and the fitted shape (e.g., circle)(red) XOR operation
    147         // should generate transparent image, if the actual icon is equivalent to the shape.
    148         mFileId = mRandom.nextInt();
    149         mBitmapARGB.eraseColor(Color.TRANSPARENT);
    150         mCanvasARGB.drawBitmap(mBitmap, 0, 0, mPaintIcon);
    151 
    152         if (DEBUG) {
    153             final File beforeFile = new File(mDir, "isShape" + mFileId + "_before.png");
    154             try {
    155                 mBitmapARGB.compress(Bitmap.CompressFormat.PNG, 100,
    156                         new FileOutputStream(beforeFile));
    157             } catch (Exception e) {}
    158         }
    159 
    160         // Fit the shape within the icon's bounding box
    161         mMatrix.reset();
    162         mMatrix.setScale(mBounds.width(), mBounds.height());
    163         mMatrix.postTranslate(mBounds.left, mBounds.top);
    164         maskPath.transform(mMatrix);
    165 
    166         // XOR operation
    167         mCanvasARGB.drawPath(maskPath, mPaintMaskShape);
    168 
    169         // DST_OUT operation around the mask path outline
    170         mCanvasARGB.drawPath(maskPath, mPaintMaskShapeOutline);
    171 
    172         boolean isTrans = isTransparentBitmap(mBitmapARGB);
    173         if (DEBUG) {
    174             final File afterFile = new File(mDir,
    175                     "isShape" + mFileId + "_after_" + isTrans + ".png");
    176             try {
    177                 mBitmapARGB.compress(Bitmap.CompressFormat.PNG, 100,
    178                         new FileOutputStream(afterFile));
    179             } catch (Exception e) {}
    180         }
    181 
    182         // Check if the result is almost transparent
    183         if (!isTrans) {
    184             if (DEBUG) {
    185                 Log.d(TAG, "Not same as mask shape");
    186             }
    187             return false;
    188         }
    189         return true;
    190     }
    191 
    192     /**
    193      * Used to determine if certain the bitmap is transparent.
    194      */
    195     private boolean isTransparentBitmap(Bitmap bitmap) {
    196         int w = mBounds.width();
    197         int h = mBounds.height();
    198         bitmap.getPixels(mPixelsARGB, 0 /* the first index to write into the array */,
    199                 w /* stride */,
    200                 mBounds.left, mBounds.top,
    201                 w, h);
    202         int sum = 0;
    203         for (int i = 0; i < w * h; i++) {
    204             if(Color.alpha(mPixelsARGB[i]) > MIN_VISIBLE_ALPHA) {
    205                     sum++;
    206             }
    207         }
    208         float percentageDiffPixels = ((float) sum) / (mBounds.width() * mBounds.height());
    209         boolean transparentImage = percentageDiffPixels < PIXEL_DIFF_PERCENTAGE_THRESHOLD;
    210         if (DEBUG) {
    211             Log.d(TAG,
    212                     "Total # pixel that is different (id=" + mFileId + "):" + percentageDiffPixels
    213                             + "=" + sum + "/" + mBounds.width() * mBounds.height());
    214         }
    215         return transparentImage;
    216     }
    217 
    218     /**
    219      * Returns the amount by which the {@param d} should be scaled (in both dimensions) so that it
    220      * matches the design guidelines for a launcher icon.
    221      *
    222      * We first calculate the convex hull of the visible portion of the icon.
    223      * This hull then compared with the bounding rectangle of the hull to find how closely it
    224      * resembles a circle and a square, by comparing the ratio of the areas. Note that this is not an
    225      * ideal solution but it gives satisfactory result without affecting the performance.
    226      *
    227      * This closeness is used to determine the ratio of hull area to the full icon size.
    228      * Refer {@link #MAX_CIRCLE_AREA_FACTOR} and {@link #MAX_SQUARE_AREA_FACTOR}
    229      *
    230      * @param outBounds optional rect to receive the fraction distance from each edge.
    231      */
    232     public synchronized float getScale(@NonNull Drawable d, @Nullable RectF outBounds,
    233             @Nullable Path path, @Nullable boolean[] outMaskShape) {
    234         if (Utilities.ATLEAST_OREO && d instanceof AdaptiveIconDrawable &&
    235                 mAdaptiveIconScale != SCALE_NOT_INITIALIZED) {
    236             if (outBounds != null) {
    237                 outBounds.set(mAdaptiveIconBounds);
    238             }
    239             return mAdaptiveIconScale;
    240         }
    241         int width = d.getIntrinsicWidth();
    242         int height = d.getIntrinsicHeight();
    243         if (width <= 0 || height <= 0) {
    244             width = width <= 0 || width > mMaxSize ? mMaxSize : width;
    245             height = height <= 0 || height > mMaxSize ? mMaxSize : height;
    246         } else if (width > mMaxSize || height > mMaxSize) {
    247             int max = Math.max(width, height);
    248             width = mMaxSize * width / max;
    249             height = mMaxSize * height / max;
    250         }
    251 
    252         mBitmap.eraseColor(Color.TRANSPARENT);
    253         d.setBounds(0, 0, width, height);
    254         d.draw(mCanvas);
    255 
    256         ByteBuffer buffer = ByteBuffer.wrap(mPixels);
    257         buffer.rewind();
    258         mBitmap.copyPixelsToBuffer(buffer);
    259 
    260         // Overall bounds of the visible icon.
    261         int topY = -1;
    262         int bottomY = -1;
    263         int leftX = mMaxSize + 1;
    264         int rightX = -1;
    265 
    266         // Create border by going through all pixels one row at a time and for each row find
    267         // the first and the last non-transparent pixel. Set those values to mLeftBorder and
    268         // mRightBorder and use -1 if there are no visible pixel in the row.
    269 
    270         // buffer position
    271         int index = 0;
    272         // buffer shift after every row, width of buffer = mMaxSize
    273         int rowSizeDiff = mMaxSize - width;
    274         // first and last position for any row.
    275         int firstX, lastX;
    276 
    277         for (int y = 0; y < height; y++) {
    278             firstX = lastX = -1;
    279             for (int x = 0; x < width; x++) {
    280                 if ((mPixels[index] & 0xFF) > MIN_VISIBLE_ALPHA) {
    281                     if (firstX == -1) {
    282                         firstX = x;
    283                     }
    284                     lastX = x;
    285                 }
    286                 index++;
    287             }
    288             index += rowSizeDiff;
    289 
    290             mLeftBorder[y] = firstX;
    291             mRightBorder[y] = lastX;
    292 
    293             // If there is at least one visible pixel, update the overall bounds.
    294             if (firstX != -1) {
    295                 bottomY = y;
    296                 if (topY == -1) {
    297                     topY = y;
    298                 }
    299 
    300                 leftX = Math.min(leftX, firstX);
    301                 rightX = Math.max(rightX, lastX);
    302             }
    303         }
    304 
    305         if (topY == -1 || rightX == -1) {
    306             // No valid pixels found. Do not scale.
    307             return 1;
    308         }
    309 
    310         convertToConvexArray(mLeftBorder, 1, topY, bottomY);
    311         convertToConvexArray(mRightBorder, -1, topY, bottomY);
    312 
    313         // Area of the convex hull
    314         float area = 0;
    315         for (int y = 0; y < height; y++) {
    316             if (mLeftBorder[y] <= -1) {
    317                 continue;
    318             }
    319             area += mRightBorder[y] - mLeftBorder[y] + 1;
    320         }
    321 
    322         // Area of the rectangle required to fit the convex hull
    323         float rectArea = (bottomY + 1 - topY) * (rightX + 1 - leftX);
    324         float hullByRect = area / rectArea;
    325 
    326         float scaleRequired;
    327         if (hullByRect < CIRCLE_AREA_BY_RECT) {
    328             scaleRequired = MAX_CIRCLE_AREA_FACTOR;
    329         } else {
    330             scaleRequired = MAX_SQUARE_AREA_FACTOR + LINEAR_SCALE_SLOPE * (1 - hullByRect);
    331         }
    332         mBounds.left = leftX;
    333         mBounds.right = rightX;
    334 
    335         mBounds.top = topY;
    336         mBounds.bottom = bottomY;
    337 
    338         if (outBounds != null) {
    339             outBounds.set(((float) mBounds.left) / width, ((float) mBounds.top),
    340                     1 - ((float) mBounds.right) / width,
    341                     1 - ((float) mBounds.bottom) / height);
    342         }
    343 
    344         if (outMaskShape != null && outMaskShape.length > 0) {
    345             outMaskShape[0] = isShape(path);
    346         }
    347         float areaScale = area / (width * height);
    348         // Use sqrt of the final ratio as the images is scaled across both width and height.
    349         float scale = areaScale > scaleRequired ? (float) Math.sqrt(scaleRequired / areaScale) : 1;
    350         if (Utilities.ATLEAST_OREO && d instanceof AdaptiveIconDrawable &&
    351                 mAdaptiveIconScale == SCALE_NOT_INITIALIZED) {
    352             mAdaptiveIconScale = scale;
    353             mAdaptiveIconBounds.set(mBounds);
    354         }
    355         return scale;
    356     }
    357 
    358     /**
    359      * Modifies {@param xCoordinates} to represent a convex border. Fills in all missing values
    360      * (except on either ends) with appropriate values.
    361      * @param xCoordinates map of x coordinate per y.
    362      * @param direction 1 for left border and -1 for right border.
    363      * @param topY the first Y position (inclusive) with a valid value.
    364      * @param bottomY the last Y position (inclusive) with a valid value.
    365      */
    366     private static void convertToConvexArray(
    367             float[] xCoordinates, int direction, int topY, int bottomY) {
    368         int total = xCoordinates.length;
    369         // The tangent at each pixel.
    370         float[] angles = new float[total - 1];
    371 
    372         int first = topY; // First valid y coordinate
    373         int last = -1;    // Last valid y coordinate which didn't have a missing value
    374 
    375         float lastAngle = Float.MAX_VALUE;
    376 
    377         for (int i = topY + 1; i <= bottomY; i++) {
    378             if (xCoordinates[i] <= -1) {
    379                 continue;
    380             }
    381             int start;
    382 
    383             if (lastAngle == Float.MAX_VALUE) {
    384                 start = first;
    385             } else {
    386                 float currentAngle = (xCoordinates[i] - xCoordinates[last]) / (i - last);
    387                 start = last;
    388                 // If this position creates a concave angle, keep moving up until we find a
    389                 // position which creates a convex angle.
    390                 if ((currentAngle - lastAngle) * direction < 0) {
    391                     while (start > first) {
    392                         start --;
    393                         currentAngle = (xCoordinates[i] - xCoordinates[start]) / (i - start);
    394                         if ((currentAngle - angles[start]) * direction >= 0) {
    395                             break;
    396                         }
    397                     }
    398                 }
    399             }
    400 
    401             // Reset from last check
    402             lastAngle = (xCoordinates[i] - xCoordinates[start]) / (i - start);
    403             // Update all the points from start.
    404             for (int j = start; j < i; j++) {
    405                 angles[j] = lastAngle;
    406                 xCoordinates[j] = xCoordinates[start] + lastAngle * (j - start);
    407             }
    408             last = i;
    409         }
    410     }
    411 
    412     public static IconNormalizer getInstance(Context context) {
    413         synchronized (LOCK) {
    414             if (sIconNormalizer == null) {
    415                 sIconNormalizer = new IconNormalizer(context);
    416             }
    417         }
    418         return sIconNormalizer;
    419     }
    420 }
    421