1/* 2 * Copyright 2014 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 17package android.support.v7.graphics; 18 19import android.graphics.Bitmap; 20import android.graphics.Color; 21import android.os.AsyncTask; 22import android.support.v4.os.AsyncTaskCompat; 23 24import java.util.Arrays; 25import java.util.Collections; 26import java.util.List; 27 28/** 29 * A helper class to extract prominent colors from an image. 30 * <p> 31 * A number of colors with different profiles are extracted from the image: 32 * <ul> 33 * <li>Vibrant</li> 34 * <li>Vibrant Dark</li> 35 * <li>Vibrant Light</li> 36 * <li>Muted</li> 37 * <li>Muted Dark</li> 38 * <li>Muted Light</li> 39 * </ul> 40 * These can be retrieved from the appropriate getter method. 41 * 42 * <p> 43 * Instances can be created with the synchronous factory methods {@link #generate(Bitmap)} and 44 * {@link #generate(Bitmap, int)}. 45 * <p> 46 * These should be called on a background thread, ideally the one in 47 * which you load your images on. Sometimes that is not possible, so asynchronous factory methods 48 * have also been provided: {@link #generateAsync(Bitmap, PaletteAsyncListener)} and 49 * {@link #generateAsync(Bitmap, int, PaletteAsyncListener)}. These can be used as so: 50 * 51 * <pre> 52 * Palette.generateAsync(bitmap, new Palette.PaletteAsyncListener() { 53 * public void onGenerated(Palette palette) { 54 * // Do something with colors... 55 * } 56 * }); 57 * </pre> 58 */ 59public final class Palette { 60 61 /** 62 * Listener to be used with {@link #generateAsync(Bitmap, PaletteAsyncListener)} or 63 * {@link #generateAsync(Bitmap, int, PaletteAsyncListener)} 64 */ 65 public interface PaletteAsyncListener { 66 67 /** 68 * Called when the {@link Palette} has been generated. 69 */ 70 void onGenerated(Palette palette); 71 } 72 73 private static final int CALCULATE_BITMAP_MIN_DIMENSION = 100; 74 private static final int DEFAULT_CALCULATE_NUMBER_COLORS = 16; 75 76 private static final float TARGET_DARK_LUMA = 0.26f; 77 private static final float MAX_DARK_LUMA = 0.45f; 78 79 private static final float MIN_LIGHT_LUMA = 0.55f; 80 private static final float TARGET_LIGHT_LUMA = 0.74f; 81 82 private static final float MIN_NORMAL_LUMA = 0.3f; 83 private static final float TARGET_NORMAL_LUMA = 0.5f; 84 private static final float MAX_NORMAL_LUMA = 0.7f; 85 86 private static final float TARGET_MUTED_SATURATION = 0.3f; 87 private static final float MAX_MUTED_SATURATION = 0.4f; 88 89 private static final float TARGET_VIBRANT_SATURATION = 1f; 90 private static final float MIN_VIBRANT_SATURATION = 0.35f; 91 92 private static final float WEIGHT_SATURATION = 3f; 93 private static final float WEIGHT_LUMA = 6f; 94 private static final float WEIGHT_POPULATION = 1f; 95 96 private static final float MIN_CONTRAST_TITLE_TEXT = 3.0f; 97 private static final float MIN_CONTRAST_BODY_TEXT = 4.5f; 98 99 private final List<Swatch> mSwatches; 100 private final int mHighestPopulation; 101 102 private Swatch mVibrantSwatch; 103 private Swatch mMutedSwatch; 104 105 private Swatch mDarkVibrantSwatch; 106 private Swatch mDarkMutedSwatch; 107 108 private Swatch mLightVibrantSwatch; 109 private Swatch mLightMutedColor; 110 111 /** 112 * Generate a {@link Palette} from a {@link Bitmap} using the default number of colors. 113 */ 114 public static Palette generate(Bitmap bitmap) { 115 return generate(bitmap, DEFAULT_CALCULATE_NUMBER_COLORS); 116 } 117 118 /** 119 * Generate a {@link Palette} from a {@link Bitmap} using the specified {@code numColors}. 120 * Good values for {@code numColors} depend on the source image type. 121 * For landscapes, a good values are in the range 12-16. For images which are largely made up 122 * of people's faces then this value should be increased to 24-32. 123 * 124 * @param numColors The maximum number of colors in the generated palette. Increasing this 125 * number will increase the time needed to compute the values. 126 */ 127 public static Palette generate(Bitmap bitmap, int numColors) { 128 checkBitmapParam(bitmap); 129 checkNumberColorsParam(numColors); 130 131 // First we'll scale down the bitmap so it's shortest dimension is 100px 132 final Bitmap scaledBitmap = scaleBitmapDown(bitmap); 133 134 // Now generate a quantizer from the Bitmap 135 ColorCutQuantizer quantizer = ColorCutQuantizer.fromBitmap(scaledBitmap, numColors); 136 137 // If created a new bitmap, recycle it 138 if (scaledBitmap != bitmap) { 139 scaledBitmap.recycle(); 140 } 141 142 // Now return a ColorExtractor instance 143 return new Palette(quantizer.getQuantizedColors()); 144 } 145 146 /** 147 * Generate a {@link Palette} asynchronously. {@link PaletteAsyncListener#onGenerated(Palette)} 148 * will be called with the created instance. The resulting {@link Palette} is the same as 149 * what would be created by calling {@link #generate(Bitmap)}. 150 * 151 * @param listener Listener to be invoked when the {@link Palette} has been generated. 152 * 153 * @return the {@link android.os.AsyncTask} used to asynchronously generate the instance. 154 */ 155 public static AsyncTask<Bitmap, Void, Palette> generateAsync( 156 Bitmap bitmap, PaletteAsyncListener listener) { 157 return generateAsync(bitmap, DEFAULT_CALCULATE_NUMBER_COLORS, listener); 158 } 159 160 /** 161 * Generate a {@link Palette} asynchronously. {@link PaletteAsyncListener#onGenerated(Palette)} 162 * will be called with the created instance. The resulting {@link Palette} is the same as what 163 * would be created by calling {@link #generate(Bitmap, int)}. 164 * 165 * @param listener Listener to be invoked when the {@link Palette} has been generated. 166 * 167 * @return the {@link android.os.AsyncTask} used to asynchronously generate the instance. 168 */ 169 public static AsyncTask<Bitmap, Void, Palette> generateAsync( 170 final Bitmap bitmap, final int numColors, final PaletteAsyncListener listener) { 171 checkBitmapParam(bitmap); 172 checkNumberColorsParam(numColors); 173 checkAsyncListenerParam(listener); 174 175 return AsyncTaskCompat.executeParallel( 176 new AsyncTask<Bitmap, Void, Palette>() { 177 @Override 178 protected Palette doInBackground(Bitmap... params) { 179 return generate(params[0], numColors); 180 } 181 182 @Override 183 protected void onPostExecute(Palette colorExtractor) { 184 listener.onGenerated(colorExtractor); 185 } 186 }, bitmap); 187 } 188 189 private Palette(List<Swatch> swatches) { 190 mSwatches = swatches; 191 mHighestPopulation = findMaxPopulation(); 192 193 mVibrantSwatch = findColor(TARGET_NORMAL_LUMA, MIN_NORMAL_LUMA, MAX_NORMAL_LUMA, 194 TARGET_VIBRANT_SATURATION, MIN_VIBRANT_SATURATION, 1f); 195 196 mLightVibrantSwatch = findColor(TARGET_LIGHT_LUMA, MIN_LIGHT_LUMA, 1f, 197 TARGET_VIBRANT_SATURATION, MIN_VIBRANT_SATURATION, 1f); 198 199 mDarkVibrantSwatch = findColor(TARGET_DARK_LUMA, 0f, MAX_DARK_LUMA, 200 TARGET_VIBRANT_SATURATION, MIN_VIBRANT_SATURATION, 1f); 201 202 mMutedSwatch = findColor(TARGET_NORMAL_LUMA, MIN_NORMAL_LUMA, MAX_NORMAL_LUMA, 203 TARGET_MUTED_SATURATION, 0f, MAX_MUTED_SATURATION); 204 205 mLightMutedColor = findColor(TARGET_LIGHT_LUMA, MIN_LIGHT_LUMA, 1f, 206 TARGET_MUTED_SATURATION, 0f, MAX_MUTED_SATURATION); 207 208 mDarkMutedSwatch = findColor(TARGET_DARK_LUMA, 0f, MAX_DARK_LUMA, 209 TARGET_MUTED_SATURATION, 0f, MAX_MUTED_SATURATION); 210 211 // Now try and generate any missing colors 212 generateEmptySwatches(); 213 } 214 215 /** 216 * Returns all of the swatches which make up the palette. 217 */ 218 public List<Swatch> getSwatches() { 219 return Collections.unmodifiableList(mSwatches); 220 } 221 222 /** 223 * Returns the most vibrant swatch in the palette. Might be null. 224 */ 225 public Swatch getVibrantSwatch() { 226 return mVibrantSwatch; 227 } 228 229 /** 230 * Returns a light and vibrant swatch from the palette. Might be null. 231 */ 232 public Swatch getLightVibrantSwatch() { 233 return mLightVibrantSwatch; 234 } 235 236 /** 237 * Returns a dark and vibrant swatch from the palette. Might be null. 238 */ 239 public Swatch getDarkVibrantSwatch() { 240 return mDarkVibrantSwatch; 241 } 242 243 /** 244 * Returns a muted swatch from the palette. Might be null. 245 */ 246 public Swatch getMutedSwatch() { 247 return mMutedSwatch; 248 } 249 250 /** 251 * Returns a muted and light swatch from the palette. Might be null. 252 */ 253 public Swatch getLightMutedSwatch() { 254 return mLightMutedColor; 255 } 256 257 /** 258 * Returns a muted and dark swatch from the palette. Might be null. 259 */ 260 public Swatch getDarkMutedSwatch() { 261 return mDarkMutedSwatch; 262 } 263 264 /** 265 * Returns the most vibrant color in the palette as an RGB packed int. 266 * 267 * @param defaultColor value to return if the swatch isn't available 268 */ 269 public int getVibrantColor(int defaultColor) { 270 return mVibrantSwatch != null ? mVibrantSwatch.getRgb() : defaultColor; 271 } 272 273 /** 274 * Returns a light and vibrant color from the palette as an RGB packed int. 275 * 276 * @param defaultColor value to return if the swatch isn't available 277 */ 278 public int getLightVibrantColor(int defaultColor) { 279 return mLightVibrantSwatch != null ? mLightVibrantSwatch.getRgb() : defaultColor; 280 } 281 282 /** 283 * Returns a dark and vibrant color from the palette as an RGB packed int. 284 * 285 * @param defaultColor value to return if the swatch isn't available 286 */ 287 public int getDarkVibrantColor(int defaultColor) { 288 return mDarkVibrantSwatch != null ? mDarkVibrantSwatch.getRgb() : defaultColor; 289 } 290 291 /** 292 * Returns a muted color from the palette as an RGB packed int. 293 * 294 * @param defaultColor value to return if the swatch isn't available 295 */ 296 public int getMutedColor(int defaultColor) { 297 return mMutedSwatch != null ? mMutedSwatch.getRgb() : defaultColor; 298 } 299 300 /** 301 * Returns a muted and light color from the palette as an RGB packed int. 302 * 303 * @param defaultColor value to return if the swatch isn't available 304 */ 305 public int getLightMutedColor(int defaultColor) { 306 return mLightMutedColor != null ? mLightMutedColor.getRgb() : defaultColor; 307 } 308 309 /** 310 * Returns a muted and dark color from the palette as an RGB packed int. 311 * 312 * @param defaultColor value to return if the swatch isn't available 313 */ 314 public int getDarkMutedColor(int defaultColor) { 315 return mDarkMutedSwatch != null ? mDarkMutedSwatch.getRgb() : defaultColor; 316 } 317 318 /** 319 * @return true if we have already selected {@code swatch} 320 */ 321 private boolean isAlreadySelected(Swatch swatch) { 322 return mVibrantSwatch == swatch || mDarkVibrantSwatch == swatch || 323 mLightVibrantSwatch == swatch || mMutedSwatch == swatch || 324 mDarkMutedSwatch == swatch || mLightMutedColor == swatch; 325 } 326 327 private Swatch findColor(float targetLuma, float minLuma, float maxLuma, 328 float targetSaturation, float minSaturation, float maxSaturation) { 329 Swatch max = null; 330 float maxValue = 0f; 331 332 for (Swatch swatch : mSwatches) { 333 final float sat = swatch.getHsl()[1]; 334 final float luma = swatch.getHsl()[2]; 335 336 if (sat >= minSaturation && sat <= maxSaturation && 337 luma >= minLuma && luma <= maxLuma && 338 !isAlreadySelected(swatch)) { 339 float thisValue = createComparisonValue(sat, targetSaturation, luma, targetLuma, 340 swatch.getPopulation(), mHighestPopulation); 341 if (max == null || thisValue > maxValue) { 342 max = swatch; 343 maxValue = thisValue; 344 } 345 } 346 } 347 348 return max; 349 } 350 351 /** 352 * Try and generate any missing swatches from the swatches we did find. 353 */ 354 private void generateEmptySwatches() { 355 if (mVibrantSwatch == null) { 356 // If we do not have a vibrant color... 357 if (mDarkVibrantSwatch != null) { 358 // ...but we do have a dark vibrant, generate the value by modifying the luma 359 final float[] newHsl = copyHslValues(mDarkVibrantSwatch); 360 newHsl[2] = TARGET_NORMAL_LUMA; 361 mVibrantSwatch = new Swatch(ColorUtils.HSLtoRGB(newHsl), 0); 362 } 363 } 364 365 if (mDarkVibrantSwatch == null) { 366 // If we do not have a dark vibrant color... 367 if (mVibrantSwatch != null) { 368 // ...but we do have a vibrant, generate the value by modifying the luma 369 final float[] newHsl = copyHslValues(mVibrantSwatch); 370 newHsl[2] = TARGET_DARK_LUMA; 371 mDarkVibrantSwatch = new Swatch(ColorUtils.HSLtoRGB(newHsl), 0); 372 } 373 } 374 } 375 376 /** 377 * Find the {@link Swatch} with the highest population value and return the population. 378 */ 379 private int findMaxPopulation() { 380 int population = 0; 381 for (Swatch swatch : mSwatches) { 382 population = Math.max(population, swatch.getPopulation()); 383 } 384 return population; 385 } 386 387 @Override 388 public boolean equals(Object o) { 389 if (this == o) { 390 return true; 391 } 392 if (o == null || getClass() != o.getClass()) { 393 return false; 394 } 395 396 Palette palette = (Palette) o; 397 398 if (mSwatches != null ? !mSwatches.equals(palette.mSwatches) : palette.mSwatches != null) { 399 return false; 400 } 401 if (mDarkMutedSwatch != null ? !mDarkMutedSwatch.equals(palette.mDarkMutedSwatch) 402 : palette.mDarkMutedSwatch != null) { 403 return false; 404 } 405 if (mDarkVibrantSwatch != null ? !mDarkVibrantSwatch.equals(palette.mDarkVibrantSwatch) 406 : palette.mDarkVibrantSwatch != null) { 407 return false; 408 } 409 if (mLightMutedColor != null ? !mLightMutedColor.equals(palette.mLightMutedColor) 410 : palette.mLightMutedColor != null) { 411 return false; 412 } 413 if (mLightVibrantSwatch != null ? !mLightVibrantSwatch.equals(palette.mLightVibrantSwatch) 414 : palette.mLightVibrantSwatch != null) { 415 return false; 416 } 417 if (mMutedSwatch != null ? !mMutedSwatch.equals(palette.mMutedSwatch) 418 : palette.mMutedSwatch != null) { 419 return false; 420 } 421 if (mVibrantSwatch != null ? !mVibrantSwatch.equals(palette.mVibrantSwatch) 422 : palette.mVibrantSwatch != null) { 423 return false; 424 } 425 426 return true; 427 } 428 429 @Override 430 public int hashCode() { 431 int result = mSwatches != null ? mSwatches.hashCode() : 0; 432 result = 31 * result + (mVibrantSwatch != null ? mVibrantSwatch.hashCode() : 0); 433 result = 31 * result + (mMutedSwatch != null ? mMutedSwatch.hashCode() : 0); 434 result = 31 * result + (mDarkVibrantSwatch != null ? mDarkVibrantSwatch.hashCode() : 0); 435 result = 31 * result + (mDarkMutedSwatch != null ? mDarkMutedSwatch.hashCode() : 0); 436 result = 31 * result + (mLightVibrantSwatch != null ? mLightVibrantSwatch.hashCode() : 0); 437 result = 31 * result + (mLightMutedColor != null ? mLightMutedColor.hashCode() : 0); 438 return result; 439 } 440 441 /** 442 * Scale the bitmap down so that it's smallest dimension is 443 * {@value #CALCULATE_BITMAP_MIN_DIMENSION}px. If {@code bitmap} is smaller than this, than it 444 * is returned. 445 */ 446 private static Bitmap scaleBitmapDown(Bitmap bitmap) { 447 final int minDimension = Math.min(bitmap.getWidth(), bitmap.getHeight()); 448 449 if (minDimension <= CALCULATE_BITMAP_MIN_DIMENSION) { 450 // If the bitmap is small enough already, just return it 451 return bitmap; 452 } 453 454 final float scaleRatio = CALCULATE_BITMAP_MIN_DIMENSION / (float) minDimension; 455 return Bitmap.createScaledBitmap(bitmap, 456 Math.round(bitmap.getWidth() * scaleRatio), 457 Math.round(bitmap.getHeight() * scaleRatio), 458 false); 459 } 460 461 private static float createComparisonValue(float saturation, float targetSaturation, 462 float luma, float targetLuma, 463 int population, int highestPopulation) { 464 return weightedMean( 465 invertDiff(saturation, targetSaturation), WEIGHT_SATURATION, 466 invertDiff(luma, targetLuma), WEIGHT_LUMA, 467 population / (float) highestPopulation, WEIGHT_POPULATION 468 ); 469 } 470 471 /** 472 * Copy a {@link Swatch}'s HSL values into a new float[]. 473 */ 474 private static float[] copyHslValues(Swatch color) { 475 final float[] newHsl = new float[3]; 476 System.arraycopy(color.getHsl(), 0, newHsl, 0, 3); 477 return newHsl; 478 } 479 480 /** 481 * Returns a value in the range 0-1. 1 is returned when {@code value} equals the 482 * {@code targetValue} and then decreases as the absolute difference between {@code value} and 483 * {@code targetValue} increases. 484 * 485 * @param value the item's value 486 * @param targetValue the value which we desire 487 */ 488 private static float invertDiff(float value, float targetValue) { 489 return 1f - Math.abs(value - targetValue); 490 } 491 492 private static float weightedMean(float... values) { 493 float sum = 0f; 494 float sumWeight = 0f; 495 496 for (int i = 0; i < values.length; i += 2) { 497 float value = values[i]; 498 float weight = values[i + 1]; 499 500 sum += (value * weight); 501 sumWeight += weight; 502 } 503 504 return sum / sumWeight; 505 } 506 507 private static void checkBitmapParam(Bitmap bitmap) { 508 if (bitmap == null) { 509 throw new IllegalArgumentException("bitmap can not be null"); 510 } 511 if (bitmap.isRecycled()) { 512 throw new IllegalArgumentException("bitmap can not be recycled"); 513 } 514 } 515 516 private static void checkNumberColorsParam(int numColors) { 517 if (numColors < 1) { 518 throw new IllegalArgumentException("numColors must be 1 of greater"); 519 } 520 } 521 522 private static void checkAsyncListenerParam(PaletteAsyncListener listener) { 523 if (listener == null) { 524 throw new IllegalArgumentException("listener can not be null"); 525 } 526 } 527 528 /** 529 * Represents a color swatch generated from an image's palette. The RGB color can be retrieved 530 * by calling {@link #getRgb()}. 531 */ 532 public static final class Swatch { 533 private final int mRed, mGreen, mBlue; 534 private final int mRgb; 535 private final int mPopulation; 536 537 private boolean mGeneratedTextColors; 538 private int mTitleTextColor; 539 private int mBodyTextColor; 540 541 private float[] mHsl; 542 543 Swatch(int rgbColor, int population) { 544 mRed = Color.red(rgbColor); 545 mGreen = Color.green(rgbColor); 546 mBlue = Color.blue(rgbColor); 547 mRgb = rgbColor; 548 mPopulation = population; 549 } 550 551 Swatch(int red, int green, int blue, int population) { 552 mRed = red; 553 mGreen = green; 554 mBlue = blue; 555 mRgb = Color.rgb(red, green, blue); 556 mPopulation = population; 557 } 558 559 /** 560 * @return this swatch's RGB color value 561 */ 562 public int getRgb() { 563 return mRgb; 564 } 565 566 /** 567 * Return this swatch's HSL values. 568 * hsv[0] is Hue [0 .. 360) 569 * hsv[1] is Saturation [0...1] 570 * hsv[2] is Lightness [0...1] 571 */ 572 public float[] getHsl() { 573 if (mHsl == null) { 574 // Lazily generate HSL values from RGB 575 mHsl = new float[3]; 576 ColorUtils.RGBtoHSL(mRed, mGreen, mBlue, mHsl); 577 } 578 return mHsl; 579 } 580 581 /** 582 * @return the number of pixels represented by this swatch 583 */ 584 public int getPopulation() { 585 return mPopulation; 586 } 587 588 /** 589 * Returns an appropriate color to use for any 'title' text which is displayed over this 590 * {@link Swatch}'s color. This color is guaranteed to have sufficient contrast. 591 */ 592 public int getTitleTextColor() { 593 ensureTextColorsGenerated(); 594 return mTitleTextColor; 595 } 596 597 /** 598 * Returns an appropriate color to use for any 'body' text which is displayed over this 599 * {@link Swatch}'s color. This color is guaranteed to have sufficient contrast. 600 */ 601 public int getBodyTextColor() { 602 ensureTextColorsGenerated(); 603 return mBodyTextColor; 604 } 605 606 private void ensureTextColorsGenerated() { 607 if (!mGeneratedTextColors) { 608 mTitleTextColor = ColorUtils.getTextColorForBackground(mRgb, 609 MIN_CONTRAST_TITLE_TEXT); 610 mBodyTextColor = ColorUtils.getTextColorForBackground(mRgb, 611 MIN_CONTRAST_BODY_TEXT); 612 mGeneratedTextColors = true; 613 } 614 } 615 616 @Override 617 public String toString() { 618 return new StringBuilder(getClass().getSimpleName()) 619 .append(" [RGB: #").append(Integer.toHexString(getRgb())).append(']') 620 .append(" [HSL: ").append(Arrays.toString(getHsl())).append(']') 621 .append(" [Population: ").append(mPopulation).append(']') 622 .append(" [Title Text: #").append(Integer.toHexString(mTitleTextColor)).append(']') 623 .append(" [Body Text: #").append(Integer.toHexString(mBodyTextColor)).append(']') 624 .toString(); 625 } 626 627 @Override 628 public boolean equals(Object o) { 629 if (this == o) { 630 return true; 631 } 632 if (o == null || getClass() != o.getClass()) { 633 return false; 634 } 635 636 Swatch swatch = (Swatch) o; 637 return mPopulation == swatch.mPopulation && mRgb == swatch.mRgb; 638 } 639 640 @Override 641 public int hashCode() { 642 return 31 * mRgb + mPopulation; 643 } 644 } 645 646} 647