1 /* 2 * Copyright (C) 2017 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 android.app; 18 19 import android.annotation.IntDef; 20 import android.annotation.NonNull; 21 import android.annotation.Nullable; 22 import android.graphics.Bitmap; 23 import android.graphics.Canvas; 24 import android.graphics.Color; 25 import android.graphics.Rect; 26 import android.graphics.drawable.Drawable; 27 import android.os.Parcel; 28 import android.os.Parcelable; 29 import android.util.Log; 30 import android.util.Size; 31 32 import com.android.internal.graphics.ColorUtils; 33 import com.android.internal.graphics.cam.Cam; 34 import com.android.internal.graphics.palette.CelebiQuantizer; 35 import com.android.internal.graphics.palette.Palette; 36 import com.android.internal.graphics.palette.VariationalKMeansQuantizer; 37 import com.android.internal.util.ContrastColorUtil; 38 39 import java.io.FileOutputStream; 40 import java.lang.annotation.Retention; 41 import java.lang.annotation.RetentionPolicy; 42 import java.util.ArrayList; 43 import java.util.Collections; 44 import java.util.HashMap; 45 import java.util.List; 46 import java.util.Map; 47 import java.util.Set; 48 49 /** 50 * Provides information about the colors of a wallpaper. 51 * <p> 52 * Exposes the 3 most visually representative colors of a wallpaper. Can be either 53 * {@link WallpaperColors#getPrimaryColor()}, {@link WallpaperColors#getSecondaryColor()} 54 * or {@link WallpaperColors#getTertiaryColor()}. 55 */ 56 public final class WallpaperColors implements Parcelable { 57 /** 58 * @hide 59 */ 60 @IntDef(prefix = "HINT_", value = {HINT_SUPPORTS_DARK_TEXT, HINT_SUPPORTS_DARK_THEME}, 61 flag = true) 62 @Retention(RetentionPolicy.SOURCE) 63 public @interface ColorsHints {} 64 65 private static final boolean DEBUG_DARK_PIXELS = false; 66 67 /** 68 * Specifies that dark text is preferred over the current wallpaper for best presentation. 69 * <p> 70 * eg. A launcher may set its text color to black if this flag is specified. 71 */ 72 public static final int HINT_SUPPORTS_DARK_TEXT = 1 << 0; 73 74 /** 75 * Specifies that dark theme is preferred over the current wallpaper for best presentation. 76 * <p> 77 * eg. A launcher may set its drawer color to black if this flag is specified. 78 */ 79 public static final int HINT_SUPPORTS_DARK_THEME = 1 << 1; 80 81 /** 82 * Specifies that this object was generated by extracting colors from a bitmap. 83 * @hide 84 */ 85 public static final int HINT_FROM_BITMAP = 1 << 2; 86 87 // Maximum size that a bitmap can have to keep our calculations valid 88 private static final int MAX_BITMAP_SIZE = 112; 89 90 // Even though we have a maximum size, we'll mainly match bitmap sizes 91 // using the area instead. This way our comparisons are aspect ratio independent. 92 private static final int MAX_WALLPAPER_EXTRACTION_AREA = MAX_BITMAP_SIZE * MAX_BITMAP_SIZE; 93 94 // When extracting the main colors, only consider colors 95 // present in at least MIN_COLOR_OCCURRENCE of the image 96 private static final float MIN_COLOR_OCCURRENCE = 0.05f; 97 98 // Decides when dark theme is optimal for this wallpaper 99 private static final float DARK_THEME_MEAN_LUMINANCE = 0.3f; 100 // Minimum mean luminosity that an image needs to have to support dark text 101 private static final float BRIGHT_IMAGE_MEAN_LUMINANCE = 0.7f; 102 // We also check if the image has dark pixels in it, 103 // to avoid bright images with some dark spots. 104 private static final float DARK_PIXEL_CONTRAST = 5.5f; 105 private static final float MAX_DARK_AREA = 0.05f; 106 107 private final List<Color> mMainColors; 108 private final Map<Integer, Integer> mAllColors; 109 private int mColorHints; 110 WallpaperColors(Parcel parcel)111 public WallpaperColors(Parcel parcel) { 112 mMainColors = new ArrayList<>(); 113 mAllColors = new HashMap<>(); 114 int count = parcel.readInt(); 115 for (int i = 0; i < count; i++) { 116 final int colorInt = parcel.readInt(); 117 Color color = Color.valueOf(colorInt); 118 mMainColors.add(color); 119 } 120 count = parcel.readInt(); 121 for (int i = 0; i < count; i++) { 122 final int colorInt = parcel.readInt(); 123 final int population = parcel.readInt(); 124 mAllColors.put(colorInt, population); 125 } 126 mColorHints = parcel.readInt(); 127 } 128 129 /** 130 * Constructs {@link WallpaperColors} from a drawable. 131 * <p> 132 * Main colors will be extracted from the drawable. 133 * 134 * @param drawable Source where to extract from. 135 */ fromDrawable(Drawable drawable)136 public static WallpaperColors fromDrawable(Drawable drawable) { 137 if (drawable == null) { 138 throw new IllegalArgumentException("Drawable cannot be null"); 139 } 140 141 Rect initialBounds = drawable.copyBounds(); 142 int width = drawable.getIntrinsicWidth(); 143 int height = drawable.getIntrinsicHeight(); 144 145 // Some drawables do not have intrinsic dimensions 146 if (width <= 0 || height <= 0) { 147 width = MAX_BITMAP_SIZE; 148 height = MAX_BITMAP_SIZE; 149 } 150 151 Size optimalSize = calculateOptimalSize(width, height); 152 Bitmap bitmap = Bitmap.createBitmap(optimalSize.getWidth(), optimalSize.getHeight(), 153 Bitmap.Config.ARGB_8888); 154 final Canvas bmpCanvas = new Canvas(bitmap); 155 drawable.setBounds(0, 0, bitmap.getWidth(), bitmap.getHeight()); 156 drawable.draw(bmpCanvas); 157 158 final WallpaperColors colors = WallpaperColors.fromBitmap(bitmap); 159 bitmap.recycle(); 160 161 drawable.setBounds(initialBounds); 162 return colors; 163 } 164 165 /** 166 * Constructs {@link WallpaperColors} from a bitmap. 167 * <p> 168 * Main colors will be extracted from the bitmap. 169 * 170 * @param bitmap Source where to extract from. 171 */ fromBitmap(@onNull Bitmap bitmap)172 public static WallpaperColors fromBitmap(@NonNull Bitmap bitmap) { 173 if (bitmap == null) { 174 throw new IllegalArgumentException("Bitmap can't be null"); 175 } 176 177 final int bitmapArea = bitmap.getWidth() * bitmap.getHeight(); 178 boolean shouldRecycle = false; 179 if (bitmapArea > MAX_WALLPAPER_EXTRACTION_AREA) { 180 shouldRecycle = true; 181 Size optimalSize = calculateOptimalSize(bitmap.getWidth(), bitmap.getHeight()); 182 bitmap = Bitmap.createScaledBitmap(bitmap, optimalSize.getWidth(), 183 optimalSize.getHeight(), false /* filter */); 184 } 185 186 final Palette palette; 187 if (ActivityManager.isLowRamDeviceStatic()) { 188 palette = Palette 189 .from(bitmap, new VariationalKMeansQuantizer()) 190 .maximumColorCount(5) 191 .resizeBitmapArea(MAX_WALLPAPER_EXTRACTION_AREA) 192 .generate(); 193 } else { 194 palette = Palette 195 .from(bitmap, new CelebiQuantizer()) 196 .maximumColorCount(128) 197 .resizeBitmapArea(MAX_WALLPAPER_EXTRACTION_AREA) 198 .generate(); 199 } 200 // Remove insignificant colors and sort swatches by population 201 final ArrayList<Palette.Swatch> swatches = new ArrayList<>(palette.getSwatches()); 202 swatches.sort((a, b) -> b.getPopulation() - a.getPopulation()); 203 204 final int swatchesSize = swatches.size(); 205 206 final Map<Integer, Integer> populationByColor = new HashMap<>(); 207 for (int i = 0; i < swatchesSize; i++) { 208 Palette.Swatch swatch = swatches.get(i); 209 int colorInt = swatch.getInt(); 210 populationByColor.put(colorInt, swatch.getPopulation()); 211 212 } 213 214 int hints = calculateDarkHints(bitmap); 215 216 if (shouldRecycle) { 217 bitmap.recycle(); 218 } 219 220 return new WallpaperColors(populationByColor, HINT_FROM_BITMAP | hints); 221 } 222 223 /** 224 * Constructs a new object from three colors. 225 * 226 * @param primaryColor Primary color. 227 * @param secondaryColor Secondary color. 228 * @param tertiaryColor Tertiary color. 229 * @see WallpaperColors#fromBitmap(Bitmap) 230 * @see WallpaperColors#fromDrawable(Drawable) 231 */ WallpaperColors(@onNull Color primaryColor, @Nullable Color secondaryColor, @Nullable Color tertiaryColor)232 public WallpaperColors(@NonNull Color primaryColor, @Nullable Color secondaryColor, 233 @Nullable Color tertiaryColor) { 234 this(primaryColor, secondaryColor, tertiaryColor, 0); 235 236 // Calculate dark theme support based on primary color. 237 final float[] tmpHsl = new float[3]; 238 ColorUtils.colorToHSL(primaryColor.toArgb(), tmpHsl); 239 final float luminance = tmpHsl[2]; 240 if (luminance < DARK_THEME_MEAN_LUMINANCE) { 241 mColorHints |= HINT_SUPPORTS_DARK_THEME; 242 } 243 } 244 245 /** 246 * Constructs a new object from three colors, where hints can be specified. 247 * 248 * @param primaryColor Primary color. 249 * @param secondaryColor Secondary color. 250 * @param tertiaryColor Tertiary color. 251 * @param colorHints A combination of color hints. 252 * @see WallpaperColors#fromBitmap(Bitmap) 253 * @see WallpaperColors#fromDrawable(Drawable) 254 */ WallpaperColors(@onNull Color primaryColor, @Nullable Color secondaryColor, @Nullable Color tertiaryColor, @ColorsHints int colorHints)255 public WallpaperColors(@NonNull Color primaryColor, @Nullable Color secondaryColor, 256 @Nullable Color tertiaryColor, @ColorsHints int colorHints) { 257 258 if (primaryColor == null) { 259 throw new IllegalArgumentException("Primary color should never be null."); 260 } 261 262 mMainColors = new ArrayList<>(3); 263 mAllColors = new HashMap<>(); 264 265 mMainColors.add(primaryColor); 266 mAllColors.put(primaryColor.toArgb(), 0); 267 if (secondaryColor != null) { 268 mMainColors.add(secondaryColor); 269 mAllColors.put(secondaryColor.toArgb(), 0); 270 } 271 if (tertiaryColor != null) { 272 if (secondaryColor == null) { 273 throw new IllegalArgumentException("tertiaryColor can't be specified when " 274 + "secondaryColor is null"); 275 } 276 mMainColors.add(tertiaryColor); 277 mAllColors.put(tertiaryColor.toArgb(), 0); 278 } 279 mColorHints = colorHints; 280 } 281 282 /** 283 * Constructs a new object from a set of colors, where hints can be specified. 284 * 285 * @param colorToPopulation Map with keys of colors, and value representing the number of 286 * occurrences of color in the wallpaper. 287 * @param colorHints A combination of color hints. 288 * @hide 289 * @see WallpaperColors#HINT_SUPPORTS_DARK_TEXT 290 * @see WallpaperColors#fromBitmap(Bitmap) 291 * @see WallpaperColors#fromDrawable(Drawable) 292 */ WallpaperColors(@onNull Map<Integer, Integer> colorToPopulation, @ColorsHints int colorHints)293 public WallpaperColors(@NonNull Map<Integer, Integer> colorToPopulation, 294 @ColorsHints int colorHints) { 295 mAllColors = colorToPopulation; 296 297 final Map<Integer, Cam> colorToCam = new HashMap<>(); 298 for (int color : colorToPopulation.keySet()) { 299 colorToCam.put(color, Cam.fromInt(color)); 300 } 301 final double[] hueProportions = hueProportions(colorToCam, colorToPopulation); 302 final Map<Integer, Double> colorToHueProportion = colorToHueProportion( 303 colorToPopulation.keySet(), colorToCam, hueProportions); 304 305 final Map<Integer, Double> colorToScore = new HashMap<>(); 306 for (Map.Entry<Integer, Double> mapEntry : colorToHueProportion.entrySet()) { 307 int color = mapEntry.getKey(); 308 double proportion = mapEntry.getValue(); 309 double score = score(colorToCam.get(color), proportion); 310 colorToScore.put(color, score); 311 } 312 ArrayList<Map.Entry<Integer, Double>> mapEntries = new ArrayList(colorToScore.entrySet()); 313 mapEntries.sort((a, b) -> b.getValue().compareTo(a.getValue())); 314 315 List<Integer> colorsByScoreDescending = new ArrayList<>(); 316 for (Map.Entry<Integer, Double> colorToScoreEntry : mapEntries) { 317 colorsByScoreDescending.add(colorToScoreEntry.getKey()); 318 } 319 320 List<Integer> mainColorInts = new ArrayList<>(); 321 findSeedColorLoop: 322 for (int color : colorsByScoreDescending) { 323 Cam cam = colorToCam.get(color); 324 for (int otherColor : mainColorInts) { 325 Cam otherCam = colorToCam.get(otherColor); 326 if (hueDiff(cam, otherCam) < 15) { 327 continue findSeedColorLoop; 328 } 329 } 330 mainColorInts.add(color); 331 } 332 List<Color> mainColors = new ArrayList<>(); 333 for (int colorInt : mainColorInts) { 334 mainColors.add(Color.valueOf(colorInt)); 335 } 336 mMainColors = mainColors; 337 mColorHints = colorHints; 338 } 339 hueDiff(Cam a, Cam b)340 private static double hueDiff(Cam a, Cam b) { 341 return (180f - Math.abs(Math.abs(a.getHue() - b.getHue()) - 180f)); 342 } 343 score(Cam cam, double proportion)344 private static double score(Cam cam, double proportion) { 345 return cam.getChroma() + (proportion * 100); 346 } 347 colorToHueProportion(Set<Integer> colors, Map<Integer, Cam> colorToCam, double[] hueProportions)348 private static Map<Integer, Double> colorToHueProportion(Set<Integer> colors, 349 Map<Integer, Cam> colorToCam, double[] hueProportions) { 350 Map<Integer, Double> colorToHueProportion = new HashMap<>(); 351 for (int color : colors) { 352 final int hue = wrapDegrees(Math.round(colorToCam.get(color).getHue())); 353 double proportion = 0.0; 354 for (int i = hue - 15; i < hue + 15; i++) { 355 proportion += hueProportions[wrapDegrees(i)]; 356 } 357 colorToHueProportion.put(color, proportion); 358 } 359 return colorToHueProportion; 360 } 361 wrapDegrees(int degrees)362 private static int wrapDegrees(int degrees) { 363 if (degrees < 0) { 364 return (degrees % 360) + 360; 365 } else if (degrees >= 360) { 366 return degrees % 360; 367 } else { 368 return degrees; 369 } 370 } 371 hueProportions(@onNull Map<Integer, Cam> colorToCam, Map<Integer, Integer> colorToPopulation)372 private static double[] hueProportions(@NonNull Map<Integer, Cam> colorToCam, 373 Map<Integer, Integer> colorToPopulation) { 374 final double[] proportions = new double[360]; 375 376 double totalPopulation = 0; 377 for (Map.Entry<Integer, Integer> entry : colorToPopulation.entrySet()) { 378 totalPopulation += entry.getValue(); 379 } 380 381 for (Map.Entry<Integer, Integer> entry : colorToPopulation.entrySet()) { 382 final int color = (int) entry.getKey(); 383 final int population = colorToPopulation.get(color); 384 final Cam cam = colorToCam.get(color); 385 final int hue = wrapDegrees(Math.round(cam.getHue())); 386 proportions[hue] = proportions[hue] + ((double) population / totalPopulation); 387 } 388 389 return proportions; 390 } 391 392 public static final @android.annotation.NonNull Creator<WallpaperColors> CREATOR = new Creator<WallpaperColors>() { 393 @Override 394 public WallpaperColors createFromParcel(Parcel in) { 395 return new WallpaperColors(in); 396 } 397 398 @Override 399 public WallpaperColors[] newArray(int size) { 400 return new WallpaperColors[size]; 401 } 402 }; 403 404 @Override describeContents()405 public int describeContents() { 406 return 0; 407 } 408 409 @Override writeToParcel(Parcel dest, int flags)410 public void writeToParcel(Parcel dest, int flags) { 411 List<Color> mainColors = getMainColors(); 412 int count = mainColors.size(); 413 dest.writeInt(count); 414 for (int i = 0; i < count; i++) { 415 Color color = mainColors.get(i); 416 dest.writeInt(color.toArgb()); 417 } 418 count = mAllColors.size(); 419 dest.writeInt(count); 420 for (Map.Entry<Integer, Integer> colorEntry : mAllColors.entrySet()) { 421 if (colorEntry.getKey() != null) { 422 dest.writeInt(colorEntry.getKey()); 423 Integer population = colorEntry.getValue(); 424 int populationInt = (population != null) ? population : 0; 425 dest.writeInt(populationInt); 426 } 427 } 428 dest.writeInt(mColorHints); 429 } 430 431 /** 432 * Gets the most visually representative color of the wallpaper. 433 * "Visually representative" means easily noticeable in the image, 434 * probably happening at high frequency. 435 * 436 * @return A color. 437 */ getPrimaryColor()438 public @NonNull Color getPrimaryColor() { 439 return mMainColors.get(0); 440 } 441 442 /** 443 * Gets the second most preeminent color of the wallpaper. Can be null. 444 * 445 * @return A color, may be null. 446 */ getSecondaryColor()447 public @Nullable Color getSecondaryColor() { 448 return mMainColors.size() < 2 ? null : mMainColors.get(1); 449 } 450 451 /** 452 * Gets the third most preeminent color of the wallpaper. Can be null. 453 * 454 * @return A color, may be null. 455 */ getTertiaryColor()456 public @Nullable Color getTertiaryColor() { 457 return mMainColors.size() < 3 ? null : mMainColors.get(2); 458 } 459 460 /** 461 * List of most preeminent colors, sorted by importance. 462 * 463 * @return List of colors. 464 * @hide 465 */ getMainColors()466 public @NonNull List<Color> getMainColors() { 467 return Collections.unmodifiableList(mMainColors); 468 } 469 470 /** 471 * Map of all colors. Key is rgb integer, value is importance of color. 472 * 473 * @return List of colors. 474 * @hide 475 */ getAllColors()476 public @NonNull Map<Integer, Integer> getAllColors() { 477 return Collections.unmodifiableMap(mAllColors); 478 } 479 480 481 @Override equals(@ullable Object o)482 public boolean equals(@Nullable Object o) { 483 if (o == null || getClass() != o.getClass()) { 484 return false; 485 } 486 487 WallpaperColors other = (WallpaperColors) o; 488 return mMainColors.equals(other.mMainColors) 489 && mAllColors.equals(other.mAllColors) 490 && mColorHints == other.mColorHints; 491 } 492 493 @Override hashCode()494 public int hashCode() { 495 return (31 * mMainColors.hashCode() * mAllColors.hashCode()) + mColorHints; 496 } 497 498 /** 499 * Returns the color hints for this instance. 500 * @return The color hints. 501 */ getColorHints()502 public @ColorsHints int getColorHints() { 503 return mColorHints; 504 } 505 506 /** 507 * Checks if image is bright and clean enough to support light text. 508 * 509 * @param source What to read. 510 * @return Whether image supports dark text or not. 511 */ calculateDarkHints(Bitmap source)512 private static int calculateDarkHints(Bitmap source) { 513 if (source == null) { 514 return 0; 515 } 516 517 int[] pixels = new int[source.getWidth() * source.getHeight()]; 518 double totalLuminance = 0; 519 final int maxDarkPixels = (int) (pixels.length * MAX_DARK_AREA); 520 int darkPixels = 0; 521 source.getPixels(pixels, 0 /* offset */, source.getWidth(), 0 /* x */, 0 /* y */, 522 source.getWidth(), source.getHeight()); 523 524 // This bitmap was already resized to fit the maximum allowed area. 525 // Let's just loop through the pixels, no sweat! 526 float[] tmpHsl = new float[3]; 527 for (int i = 0; i < pixels.length; i++) { 528 ColorUtils.colorToHSL(pixels[i], tmpHsl); 529 final float luminance = tmpHsl[2]; 530 final int alpha = Color.alpha(pixels[i]); 531 // Make sure we don't have a dark pixel mass that will 532 // make text illegible. 533 final boolean satisfiesTextContrast = ContrastColorUtil 534 .calculateContrast(pixels[i], Color.BLACK) > DARK_PIXEL_CONTRAST; 535 if (!satisfiesTextContrast && alpha != 0) { 536 darkPixels++; 537 if (DEBUG_DARK_PIXELS) { 538 pixels[i] = Color.RED; 539 } 540 } 541 totalLuminance += luminance; 542 } 543 544 int hints = 0; 545 double meanLuminance = totalLuminance / pixels.length; 546 if (meanLuminance > BRIGHT_IMAGE_MEAN_LUMINANCE && darkPixels < maxDarkPixels) { 547 hints |= HINT_SUPPORTS_DARK_TEXT; 548 } 549 if (meanLuminance < DARK_THEME_MEAN_LUMINANCE) { 550 hints |= HINT_SUPPORTS_DARK_THEME; 551 } 552 553 if (DEBUG_DARK_PIXELS) { 554 try (FileOutputStream out = new FileOutputStream("/data/pixels.png")) { 555 source.setPixels(pixels, 0, source.getWidth(), 0, 0, source.getWidth(), 556 source.getHeight()); 557 source.compress(Bitmap.CompressFormat.PNG, 100, out); 558 } catch (Exception e) { 559 e.printStackTrace(); 560 } 561 Log.d("WallpaperColors", "l: " + meanLuminance + ", d: " + darkPixels + 562 " maxD: " + maxDarkPixels + " numPixels: " + pixels.length); 563 } 564 565 return hints; 566 } 567 calculateOptimalSize(int width, int height)568 private static Size calculateOptimalSize(int width, int height) { 569 // Calculate how big the bitmap needs to be. 570 // This avoids unnecessary processing and allocation inside Palette. 571 final int requestedArea = width * height; 572 double scale = 1; 573 if (requestedArea > MAX_WALLPAPER_EXTRACTION_AREA) { 574 scale = Math.sqrt(MAX_WALLPAPER_EXTRACTION_AREA / (double) requestedArea); 575 } 576 int newWidth = (int) (width * scale); 577 int newHeight = (int) (height * scale); 578 // Dealing with edge cases of the drawable being too wide or too tall. 579 // Width or height would end up being 0, in this case we'll set it to 1. 580 if (newWidth == 0) { 581 newWidth = 1; 582 } 583 if (newHeight == 0) { 584 newHeight = 1; 585 } 586 587 return new Size(newWidth, newHeight); 588 } 589 590 @Override toString()591 public String toString() { 592 final StringBuilder colors = new StringBuilder(); 593 for (int i = 0; i < mMainColors.size(); i++) { 594 colors.append(Integer.toHexString(mMainColors.get(i).toArgb())).append(" "); 595 } 596 return "[WallpaperColors: " + colors.toString() + "h: " + mColorHints + "]"; 597 } 598 } 599