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