Palette.java revision 1a4412ccda7b3e7818bdeceb60cc1e5ca9a65e34
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.annotation.ColorInt; 23import android.support.annotation.Nullable; 24import android.support.v4.graphics.ColorUtils; 25import android.support.v4.os.AsyncTaskCompat; 26import android.util.TimingLogger; 27 28import java.util.Arrays; 29import java.util.Collections; 30import java.util.List; 31 32/** 33 * A helper class to extract prominent colors from an image. 34 * <p> 35 * A number of colors with different profiles are extracted from the image: 36 * <ul> 37 * <li>Vibrant</li> 38 * <li>Vibrant Dark</li> 39 * <li>Vibrant Light</li> 40 * <li>Muted</li> 41 * <li>Muted Dark</li> 42 * <li>Muted Light</li> 43 * </ul> 44 * These can be retrieved from the appropriate getter method. 45 * 46 * <p> 47 * Instances are created with a {@link Builder} which supports several options to tweak the 48 * generated Palette. See that class' documentation for more information. 49 * <p> 50 * Generation should always be completed on a background thread, ideally the one in 51 * which you load your image on. {@link Builder} supports both synchronous and asynchronous 52 * generation: 53 * 54 * <pre> 55 * // Synchronous 56 * Palette p = Palette.from(bitmap).generate(); 57 * 58 * // Asynchronous 59 * Palette.from(bitmap).generate(new PaletteAsyncListener() { 60 * public void onGenerated(Palette p) { 61 * // Use generated instance 62 * } 63 * }); 64 * </pre> 65 */ 66public final class Palette { 67 68 /** 69 * Listener to be used with {@link #generateAsync(Bitmap, PaletteAsyncListener)} or 70 * {@link #generateAsync(Bitmap, int, PaletteAsyncListener)} 71 */ 72 public interface PaletteAsyncListener { 73 74 /** 75 * Called when the {@link Palette} has been generated. 76 */ 77 void onGenerated(Palette palette); 78 } 79 80 private static final int DEFAULT_RESIZE_BITMAP_MAX_DIMENSION = 192; 81 private static final int DEFAULT_CALCULATE_NUMBER_COLORS = 16; 82 83 private static final float MIN_CONTRAST_TITLE_TEXT = 3.0f; 84 private static final float MIN_CONTRAST_BODY_TEXT = 4.5f; 85 86 private static final String LOG_TAG = "Palette"; 87 private static final boolean LOG_TIMINGS = false; 88 89 /** 90 * Start generating a {@link Palette} with the returned {@link Builder} instance. 91 */ 92 public static Builder from(Bitmap bitmap) { 93 return new Builder(bitmap); 94 } 95 96 /** 97 * Generate a {@link Palette} from the pre-generated list of {@link Palette.Swatch} swatches. 98 * This is useful for testing, or if you want to resurrect a {@link Palette} instance from a 99 * list of swatches. Will return null if the {@code swatches} is null. 100 */ 101 public static Palette from(List<Swatch> swatches) { 102 return new Builder(swatches).generate(); 103 } 104 105 /** 106 * @deprecated Use {@link Builder} to generate the Palette. 107 */ 108 @Deprecated 109 public static Palette generate(Bitmap bitmap) { 110 return from(bitmap).generate(); 111 } 112 113 /** 114 * @deprecated Use {@link Builder} to generate the Palette. 115 */ 116 @Deprecated 117 public static Palette generate(Bitmap bitmap, int numColors) { 118 return from(bitmap).maximumColorCount(numColors).generate(); 119 } 120 121 /** 122 * @deprecated Use {@link Builder} to generate the Palette. 123 */ 124 @Deprecated 125 public static AsyncTask<Bitmap, Void, Palette> generateAsync( 126 Bitmap bitmap, PaletteAsyncListener listener) { 127 return from(bitmap).generate(listener); 128 } 129 130 /** 131 * @deprecated Use {@link Builder} to generate the Palette. 132 */ 133 @Deprecated 134 public static AsyncTask<Bitmap, Void, Palette> generateAsync( 135 final Bitmap bitmap, final int numColors, final PaletteAsyncListener listener) { 136 return from(bitmap).maximumColorCount(numColors).generate(listener); 137 } 138 139 private final List<Swatch> mSwatches; 140 private final Generator mGenerator; 141 142 private Palette(List<Swatch> swatches, Generator generator) { 143 mSwatches = swatches; 144 mGenerator = generator; 145 } 146 147 /** 148 * Returns all of the swatches which make up the palette. 149 */ 150 public List<Swatch> getSwatches() { 151 return Collections.unmodifiableList(mSwatches); 152 } 153 154 /** 155 * Returns the most vibrant swatch in the palette. Might be null. 156 */ 157 @Nullable 158 public Swatch getVibrantSwatch() { 159 return mGenerator.getVibrantSwatch(); 160 } 161 162 /** 163 * Returns a light and vibrant swatch from the palette. Might be null. 164 */ 165 @Nullable 166 public Swatch getLightVibrantSwatch() { 167 return mGenerator.getLightVibrantSwatch(); 168 } 169 170 /** 171 * Returns a dark and vibrant swatch from the palette. Might be null. 172 */ 173 @Nullable 174 public Swatch getDarkVibrantSwatch() { 175 return mGenerator.getDarkVibrantSwatch(); 176 } 177 178 /** 179 * Returns a muted swatch from the palette. Might be null. 180 */ 181 @Nullable 182 public Swatch getMutedSwatch() { 183 return mGenerator.getMutedSwatch(); 184 } 185 186 /** 187 * Returns a muted and light swatch from the palette. Might be null. 188 */ 189 @Nullable 190 public Swatch getLightMutedSwatch() { 191 return mGenerator.getLightMutedSwatch(); 192 } 193 194 /** 195 * Returns a muted and dark swatch from the palette. Might be null. 196 */ 197 @Nullable 198 public Swatch getDarkMutedSwatch() { 199 return mGenerator.getDarkMutedSwatch(); 200 } 201 202 /** 203 * Returns the most vibrant color in the palette as an RGB packed int. 204 * 205 * @param defaultColor value to return if the swatch isn't available 206 */ 207 @ColorInt 208 public int getVibrantColor(@ColorInt int defaultColor) { 209 Swatch swatch = getVibrantSwatch(); 210 return swatch != null ? swatch.getRgb() : defaultColor; 211 } 212 213 /** 214 * Returns a light and vibrant color from the palette as an RGB packed int. 215 * 216 * @param defaultColor value to return if the swatch isn't available 217 */ 218 @ColorInt 219 public int getLightVibrantColor(@ColorInt int defaultColor) { 220 Swatch swatch = getLightVibrantSwatch(); 221 return swatch != null ? swatch.getRgb() : defaultColor; 222 } 223 224 /** 225 * Returns a dark and vibrant color from the palette as an RGB packed int. 226 * 227 * @param defaultColor value to return if the swatch isn't available 228 */ 229 @ColorInt 230 public int getDarkVibrantColor(@ColorInt int defaultColor) { 231 Swatch swatch = getDarkVibrantSwatch(); 232 return swatch != null ? swatch.getRgb() : defaultColor; 233 } 234 235 /** 236 * Returns a muted color from the palette as an RGB packed int. 237 * 238 * @param defaultColor value to return if the swatch isn't available 239 */ 240 @ColorInt 241 public int getMutedColor(@ColorInt int defaultColor) { 242 Swatch swatch = getMutedSwatch(); 243 return swatch != null ? swatch.getRgb() : defaultColor; 244 } 245 246 /** 247 * Returns a muted and light color from the palette as an RGB packed int. 248 * 249 * @param defaultColor value to return if the swatch isn't available 250 */ 251 @ColorInt 252 public int getLightMutedColor(@ColorInt int defaultColor) { 253 Swatch swatch = getLightMutedSwatch(); 254 return swatch != null ? swatch.getRgb() : defaultColor; 255 } 256 257 /** 258 * Returns a muted and dark color from the palette as an RGB packed int. 259 * 260 * @param defaultColor value to return if the swatch isn't available 261 */ 262 @ColorInt 263 public int getDarkMutedColor(@ColorInt int defaultColor) { 264 Swatch swatch = getDarkMutedSwatch(); 265 return swatch != null ? swatch.getRgb() : defaultColor; 266 } 267 268 /** 269 * Scale the bitmap down so that it's largest dimension is {@code targetMaxDimension}. 270 * If {@code bitmap} is smaller than this, then it is returned. 271 */ 272 private static Bitmap scaleBitmapDown(Bitmap bitmap, final int targetMaxDimension) { 273 final int maxDimension = Math.max(bitmap.getWidth(), bitmap.getHeight()); 274 275 if (maxDimension <= targetMaxDimension) { 276 // If the bitmap is small enough already, just return it 277 return bitmap; 278 } 279 280 final float scaleRatio = targetMaxDimension / (float) maxDimension; 281 return Bitmap.createScaledBitmap(bitmap, 282 Math.round(bitmap.getWidth() * scaleRatio), 283 Math.round(bitmap.getHeight() * scaleRatio), 284 false); 285 } 286 287 /** 288 * Represents a color swatch generated from an image's palette. The RGB color can be retrieved 289 * by calling {@link #getRgb()}. 290 */ 291 public static final class Swatch { 292 private final int mRed, mGreen, mBlue; 293 private final int mRgb; 294 private final int mPopulation; 295 296 private boolean mGeneratedTextColors; 297 private int mTitleTextColor; 298 private int mBodyTextColor; 299 300 private float[] mHsl; 301 302 public Swatch(@ColorInt int color, int population) { 303 mRed = Color.red(color); 304 mGreen = Color.green(color); 305 mBlue = Color.blue(color); 306 mRgb = color; 307 mPopulation = population; 308 } 309 310 Swatch(int red, int green, int blue, int population) { 311 mRed = red; 312 mGreen = green; 313 mBlue = blue; 314 mRgb = Color.rgb(red, green, blue); 315 mPopulation = population; 316 } 317 318 /** 319 * @return this swatch's RGB color value 320 */ 321 @ColorInt 322 public int getRgb() { 323 return mRgb; 324 } 325 326 /** 327 * Return this swatch's HSL values. 328 * hsv[0] is Hue [0 .. 360) 329 * hsv[1] is Saturation [0...1] 330 * hsv[2] is Lightness [0...1] 331 */ 332 public float[] getHsl() { 333 if (mHsl == null) { 334 mHsl = new float[3]; 335 ColorUtils.RGBToHSL(mRed, mGreen, mBlue, mHsl); 336 } 337 return mHsl; 338 } 339 340 /** 341 * @return the number of pixels represented by this swatch 342 */ 343 public int getPopulation() { 344 return mPopulation; 345 } 346 347 /** 348 * Returns an appropriate color to use for any 'title' text which is displayed over this 349 * {@link Swatch}'s color. This color is guaranteed to have sufficient contrast. 350 */ 351 @ColorInt 352 public int getTitleTextColor() { 353 ensureTextColorsGenerated(); 354 return mTitleTextColor; 355 } 356 357 /** 358 * Returns an appropriate color to use for any 'body' text which is displayed over this 359 * {@link Swatch}'s color. This color is guaranteed to have sufficient contrast. 360 */ 361 @ColorInt 362 public int getBodyTextColor() { 363 ensureTextColorsGenerated(); 364 return mBodyTextColor; 365 } 366 367 private void ensureTextColorsGenerated() { 368 if (!mGeneratedTextColors) { 369 // First check white, as most colors will be dark 370 final int lightBodyAlpha = ColorUtils.calculateMinimumAlpha( 371 Color.WHITE, mRgb, MIN_CONTRAST_BODY_TEXT); 372 final int lightTitleAlpha = ColorUtils.calculateMinimumAlpha( 373 Color.WHITE, mRgb, MIN_CONTRAST_TITLE_TEXT); 374 375 if (lightBodyAlpha != -1 && lightTitleAlpha != -1) { 376 // If we found valid light values, use them and return 377 mBodyTextColor = ColorUtils.setAlphaComponent(Color.WHITE, lightBodyAlpha); 378 mTitleTextColor = ColorUtils.setAlphaComponent(Color.WHITE, lightTitleAlpha); 379 mGeneratedTextColors = true; 380 return; 381 } 382 383 final int darkBodyAlpha = ColorUtils.calculateMinimumAlpha( 384 Color.BLACK, mRgb, MIN_CONTRAST_BODY_TEXT); 385 final int darkTitleAlpha = ColorUtils.calculateMinimumAlpha( 386 Color.BLACK, mRgb, MIN_CONTRAST_TITLE_TEXT); 387 388 if (darkBodyAlpha != -1 && darkBodyAlpha != -1) { 389 // If we found valid dark values, use them and return 390 mBodyTextColor = ColorUtils.setAlphaComponent(Color.BLACK, darkBodyAlpha); 391 mTitleTextColor = ColorUtils.setAlphaComponent(Color.BLACK, darkTitleAlpha); 392 mGeneratedTextColors = true; 393 return; 394 } 395 396 // If we reach here then we can not find title and body values which use the same 397 // lightness, we need to use mismatched values 398 mBodyTextColor = lightBodyAlpha != -1 399 ? ColorUtils.setAlphaComponent(Color.WHITE, lightBodyAlpha) 400 : ColorUtils.setAlphaComponent(Color.BLACK, darkBodyAlpha); 401 mTitleTextColor = lightTitleAlpha != -1 402 ? ColorUtils.setAlphaComponent(Color.WHITE, lightTitleAlpha) 403 : ColorUtils.setAlphaComponent(Color.BLACK, darkTitleAlpha); 404 mGeneratedTextColors = true; 405 } 406 } 407 408 @Override 409 public String toString() { 410 return new StringBuilder(getClass().getSimpleName()) 411 .append(" [RGB: #").append(Integer.toHexString(getRgb())).append(']') 412 .append(" [HSL: ").append(Arrays.toString(getHsl())).append(']') 413 .append(" [Population: ").append(mPopulation).append(']') 414 .append(" [Title Text: #").append(Integer.toHexString(getTitleTextColor())) 415 .append(']') 416 .append(" [Body Text: #").append(Integer.toHexString(getBodyTextColor())) 417 .append(']').toString(); 418 } 419 420 @Override 421 public boolean equals(Object o) { 422 if (this == o) { 423 return true; 424 } 425 if (o == null || getClass() != o.getClass()) { 426 return false; 427 } 428 429 Swatch swatch = (Swatch) o; 430 return mPopulation == swatch.mPopulation && mRgb == swatch.mRgb; 431 } 432 433 @Override 434 public int hashCode() { 435 return 31 * mRgb + mPopulation; 436 } 437 } 438 439 /** 440 * Builder class for generating {@link Palette} instances. 441 */ 442 public static final class Builder { 443 private List<Swatch> mSwatches; 444 private Bitmap mBitmap; 445 private int mMaxColors = DEFAULT_CALCULATE_NUMBER_COLORS; 446 private int mResizeMaxDimension = DEFAULT_RESIZE_BITMAP_MAX_DIMENSION; 447 448 private Generator mGenerator; 449 450 /** 451 * Construct a new {@link Builder} using a source {@link Bitmap} 452 */ 453 public Builder(Bitmap bitmap) { 454 if (bitmap == null || bitmap.isRecycled()) { 455 throw new IllegalArgumentException("Bitmap is not valid"); 456 } 457 mBitmap = bitmap; 458 } 459 460 /** 461 * Construct a new {@link Builder} using a list of {@link Swatch} instances. 462 * Typically only used for testing. 463 */ 464 public Builder(List<Swatch> swatches) { 465 if (swatches == null || swatches.isEmpty()) { 466 throw new IllegalArgumentException("List of Swatches is not valid"); 467 } 468 mSwatches = swatches; 469 } 470 471 /** 472 * Set the {@link Generator} to use when generating the {@link Palette}. If this is called 473 * with {@code null} then the default generator will be used. 474 */ 475 Builder generator(Generator generator) { 476 mGenerator = generator; 477 return this; 478 } 479 480 /** 481 * Set the maximum number of colors to use in the quantization step when using a 482 * {@link android.graphics.Bitmap} as the source. 483 * <p> 484 * Good values for depend on the source image type. For landscapes, good values are in 485 * the range 10-16. For images which are largely made up of people's faces then this 486 * value should be increased to ~24. 487 */ 488 public Builder maximumColorCount(int colors) { 489 mMaxColors = colors; 490 return this; 491 } 492 493 /** 494 * Set the resize value when using a {@link android.graphics.Bitmap} as the source. 495 * If the bitmap's largest dimension is greater than the value specified, then the bitmap 496 * will be resized so that it's largest dimension matches {@code maxDimension}. If the 497 * bitmap is smaller or equal, the original is used as-is. 498 * <p> 499 * This value has a large effect on the processing time. The larger the resized image is, 500 * the greater time it will take to generate the palette. The smaller the image is, the 501 * more detail is lost in the resulting image and thus less precision for color selection. 502 */ 503 public Builder resizeBitmapSize(int maxDimension) { 504 mResizeMaxDimension = maxDimension; 505 return this; 506 } 507 508 /** 509 * Generate and return the {@link Palette} synchronously. 510 */ 511 public Palette generate() { 512 final TimingLogger logger = LOG_TIMINGS 513 ? new TimingLogger(LOG_TAG, "Generation") 514 : null; 515 516 List<Swatch> swatches; 517 518 if (mBitmap != null) { 519 // We have a Bitmap so we need to quantization to reduce the number of colors 520 521 if (mResizeMaxDimension <= 0) { 522 throw new IllegalArgumentException( 523 "Minimum dimension size for resizing should should be >= 1"); 524 } 525 526 // First we'll scale down the bitmap so it's largest dimension is as specified 527 final Bitmap scaledBitmap = scaleBitmapDown(mBitmap, mResizeMaxDimension); 528 529 if (logger != null) { 530 logger.addSplit("Processed Bitmap"); 531 } 532 533 // Now generate a quantizer from the Bitmap 534 ColorCutQuantizer quantizer = ColorCutQuantizer 535 .fromBitmap(scaledBitmap, mMaxColors); 536 537 // If created a new bitmap, recycle it 538 if (scaledBitmap != mBitmap) { 539 scaledBitmap.recycle(); 540 } 541 swatches = quantizer.getQuantizedColors(); 542 543 if (logger != null) { 544 logger.addSplit("Color quantization completed"); 545 } 546 } else { 547 // Else we're using the provided swatches 548 swatches = mSwatches; 549 } 550 551 // If we haven't been provided with a generator, use the default 552 if (mGenerator == null) { 553 mGenerator = new DefaultGenerator(); 554 } 555 556 // Now call let the Generator do it's thing 557 mGenerator.generate(swatches); 558 559 if (logger != null) { 560 logger.addSplit("Generator.generate() completed"); 561 } 562 563 // Now create a Palette instance 564 Palette p = new Palette(swatches, mGenerator); 565 566 if (logger != null) { 567 logger.addSplit("Created Palette"); 568 logger.dumpToLog(); 569 } 570 571 return p; 572 } 573 574 /** 575 * Generate the {@link Palette} asynchronously. The provided listener's 576 * {@link PaletteAsyncListener#onGenerated} method will be called with the palette when 577 * generated. 578 */ 579 public AsyncTask<Bitmap, Void, Palette> generate(final PaletteAsyncListener listener) { 580 if (listener == null) { 581 throw new IllegalArgumentException("listener can not be null"); 582 } 583 584 return AsyncTaskCompat.executeParallel( 585 new AsyncTask<Bitmap, Void, Palette>() { 586 @Override 587 protected Palette doInBackground(Bitmap... params) { 588 return generate(); 589 } 590 591 @Override 592 protected void onPostExecute(Palette colorExtractor) { 593 listener.onGenerated(colorExtractor); 594 } 595 }, mBitmap); 596 } 597 } 598 599 static abstract class Generator { 600 601 /** 602 * This method will be called with the {@link Palette.Swatch} that represent an image. 603 * You should process this list so that you have appropriate values when the other methods in 604 * class are called. 605 * <p> 606 * This method will probably be called on a background thread. 607 */ 608 public abstract void generate(List<Palette.Swatch> swatches); 609 610 /** 611 * Return the most vibrant {@link Palette.Swatch} 612 */ 613 public Palette.Swatch getVibrantSwatch() { 614 return null; 615 } 616 617 /** 618 * Return a light and vibrant {@link Palette.Swatch} 619 */ 620 public Palette.Swatch getLightVibrantSwatch() { 621 return null; 622 } 623 624 /** 625 * Return a dark and vibrant {@link Palette.Swatch} 626 */ 627 public Palette.Swatch getDarkVibrantSwatch() { 628 return null; 629 } 630 631 /** 632 * Return a muted {@link Palette.Swatch} 633 */ 634 public Palette.Swatch getMutedSwatch() { 635 return null; 636 } 637 638 /** 639 * Return a muted and light {@link Palette.Swatch} 640 */ 641 public Palette.Swatch getLightMutedSwatch() { 642 return null; 643 } 644 645 /** 646 * Return a muted and dark {@link Palette.Swatch} 647 */ 648 public Palette.Swatch getDarkMutedSwatch() { 649 return null; 650 } 651 } 652 653} 654