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 com.android.internal.graphics.palette; 18 19 import android.annotation.ColorInt; 20 import android.annotation.NonNull; 21 import android.annotation.Nullable; 22 import android.annotation.Px; 23 import android.graphics.Bitmap; 24 import android.graphics.Color; 25 import android.graphics.Rect; 26 import android.util.Log; 27 28 import java.util.Collections; 29 import java.util.List; 30 31 32 /** 33 * A helper class to extract prominent colors from an image. 34 * 35 * <p>Instances are created with a {@link Builder} which supports several options to tweak the 36 * generated Palette. See that class' documentation for more information. 37 * 38 * <p>Generation should always be completed on a background thread, ideally the one in which you 39 * load your image on. {@link Builder} supports both synchronous and asynchronous generation: 40 * 41 * <pre> 42 * // Synchronous 43 * Palette p = Palette.from(bitmap).generate(); 44 * 45 * // Asynchronous 46 * Palette.from(bitmap).generate(new PaletteAsyncListener() { 47 * public void onGenerated(Palette p) { 48 * // Use generated instance 49 * } 50 * }); 51 * </pre> 52 */ 53 public final class Palette { 54 55 /** 56 * Listener to be used with {@link #generateAsync(Bitmap, Palette.PaletteAsyncListener)} or 57 * {@link #generateAsync(Bitmap, int, Palette.PaletteAsyncListener)} 58 */ 59 public interface PaletteAsyncListener { 60 61 /** 62 * Called when the {@link Palette} has been generated. 63 */ onGenerated(@ullable Palette palette)64 void onGenerated(@Nullable Palette palette); 65 } 66 67 static final int DEFAULT_RESIZE_BITMAP_AREA = 112 * 112; 68 static final int DEFAULT_CALCULATE_NUMBER_COLORS = 16; 69 static final String LOG_TAG = "Palette"; 70 71 /** Start generating a {@link Palette} with the returned {@link Builder} instance. */ 72 @NonNull from(@onNull Bitmap bitmap, @NonNull Quantizer quantizer)73 public static Builder from(@NonNull Bitmap bitmap, @NonNull Quantizer quantizer) { 74 return new Builder(bitmap, quantizer); 75 } 76 77 /** 78 * Generate a {@link Palette} from the pre-generated list of {@link Palette.Swatch} swatches. 79 * This 80 * is useful for testing, or if you want to resurrect a {@link Palette} instance from a list of 81 * swatches. Will return null if the {@code swatches} is null. 82 */ 83 @NonNull from(@onNull List<Swatch> swatches)84 public static Palette from(@NonNull List<Swatch> swatches) { 85 return new Builder(swatches).generate(); 86 } 87 88 private final List<Swatch> mSwatches; 89 90 91 @Nullable 92 private final Swatch mDominantSwatch; 93 Palette(List<Swatch> swatches)94 Palette(List<Swatch> swatches) { 95 mSwatches = swatches; 96 mDominantSwatch = findDominantSwatch(); 97 } 98 99 /** Returns all of the swatches which make up the palette. */ 100 @NonNull getSwatches()101 public List<Swatch> getSwatches() { 102 return Collections.unmodifiableList(mSwatches); 103 } 104 105 /** Returns the swatch with the highest population, or null if there are no swatches. */ 106 @Nullable getDominantSwatch()107 public Swatch getDominantSwatch() { 108 return mDominantSwatch; 109 } 110 111 @Nullable findDominantSwatch()112 private Swatch findDominantSwatch() { 113 int maxPop = Integer.MIN_VALUE; 114 Swatch maxSwatch = null; 115 for (int i = 0, count = mSwatches.size(); i < count; i++) { 116 Swatch swatch = mSwatches.get(i); 117 if (swatch.getPopulation() > maxPop) { 118 maxSwatch = swatch; 119 maxPop = swatch.getPopulation(); 120 } 121 } 122 return maxSwatch; 123 } 124 125 /** 126 * Represents a color swatch generated from an image's palette. The RGB color can be retrieved 127 * by 128 * calling {@link #getInt()}. 129 */ 130 public static class Swatch { 131 private final Color mColor; 132 private final int mPopulation; 133 134 Swatch(@olorInt int colorInt, int population)135 public Swatch(@ColorInt int colorInt, int population) { 136 mColor = Color.valueOf(colorInt); 137 mPopulation = population; 138 } 139 140 /** @return this swatch's RGB color value */ 141 @ColorInt getInt()142 public int getInt() { 143 return mColor.toArgb(); 144 } 145 146 /** @return the number of pixels represented by this swatch */ getPopulation()147 public int getPopulation() { 148 return mPopulation; 149 } 150 151 @Override toString()152 public String toString() { 153 return new StringBuilder(getClass().getSimpleName()) 154 .append(" [") 155 .append(mColor) 156 .append(']') 157 .append(" [Population: ") 158 .append(mPopulation) 159 .append(']') 160 .toString(); 161 } 162 163 @Override equals(Object o)164 public boolean equals(Object o) { 165 if (this == o) { 166 return true; 167 } 168 if (o == null || getClass() != o.getClass()) { 169 return false; 170 } 171 172 Swatch swatch = (Swatch) o; 173 return mPopulation == swatch.mPopulation && mColor.toArgb() == swatch.mColor.toArgb(); 174 } 175 176 @Override hashCode()177 public int hashCode() { 178 return 31 * mColor.toArgb() + mPopulation; 179 } 180 } 181 182 /** Builder class for generating {@link Palette} instances. */ 183 public static class Builder { 184 @Nullable 185 private final List<Swatch> mSwatches; 186 @Nullable 187 private final Bitmap mBitmap; 188 @Nullable 189 private Quantizer mQuantizer = new ColorCutQuantizer(); 190 191 192 private int mMaxColors = DEFAULT_CALCULATE_NUMBER_COLORS; 193 private int mResizeArea = DEFAULT_RESIZE_BITMAP_AREA; 194 private int mResizeMaxDimension = -1; 195 196 @Nullable 197 private Rect mRegion; 198 199 /** Construct a new {@link Builder} using a source {@link Bitmap} */ Builder(@onNull Bitmap bitmap, @NonNull Quantizer quantizer)200 public Builder(@NonNull Bitmap bitmap, @NonNull Quantizer quantizer) { 201 if (bitmap == null || bitmap.isRecycled()) { 202 throw new IllegalArgumentException("Bitmap is not valid"); 203 } 204 mSwatches = null; 205 mBitmap = bitmap; 206 mQuantizer = quantizer == null ? new ColorCutQuantizer() : quantizer; 207 } 208 209 /** 210 * Construct a new {@link Builder} using a list of {@link Swatch} instances. Typically only 211 * used 212 * for testing. 213 */ Builder(@onNull List<Swatch> swatches)214 public Builder(@NonNull List<Swatch> swatches) { 215 if (swatches == null || swatches.isEmpty()) { 216 throw new IllegalArgumentException("List of Swatches is not valid"); 217 } 218 mSwatches = swatches; 219 mBitmap = null; 220 mQuantizer = null; 221 } 222 223 /** 224 * Set the maximum number of colors to use in the quantization step when using a {@link 225 * android.graphics.Bitmap} as the source. 226 * 227 * <p>Good values for depend on the source image type. For landscapes, good values are in 228 * the 229 * range 10-16. For images which are largely made up of people's faces then this value 230 * should be 231 * increased to ~24. 232 */ 233 @NonNull maximumColorCount(int colors)234 public Builder maximumColorCount(int colors) { 235 mMaxColors = colors; 236 return this; 237 } 238 239 /** 240 * Set the resize value when using a {@link android.graphics.Bitmap} as the source. If the 241 * bitmap's largest dimension is greater than the value specified, then the bitmap will be 242 * resized so that its largest dimension matches {@code maxDimension}. If the bitmap is 243 * smaller 244 * or equal, the original is used as-is. 245 * 246 * @param maxDimension the number of pixels that the max dimension should be scaled down to, 247 * or 248 * any value <= 0 to disable resizing. 249 * @deprecated Using {@link #resizeBitmapArea(int)} is preferred since it can handle 250 * abnormal 251 * aspect ratios more gracefully. 252 */ 253 @NonNull 254 @Deprecated resizeBitmapSize(int maxDimension)255 public Builder resizeBitmapSize(int maxDimension) { 256 mResizeMaxDimension = maxDimension; 257 mResizeArea = -1; 258 return this; 259 } 260 261 /** 262 * Set the resize value when using a {@link android.graphics.Bitmap} as the source. If the 263 * bitmap's area is greater than the value specified, then the bitmap will be resized so 264 * that 265 * its area matches {@code area}. If the bitmap is smaller or equal, the original is used 266 * as-is. 267 * 268 * <p>This value has a large effect on the processing time. The larger the resized image is, 269 * the 270 * greater time it will take to generate the palette. The smaller the image is, the more 271 * detail 272 * is lost in the resulting image and thus less precision for color selection. 273 * 274 * @param area the number of pixels that the intermediary scaled down Bitmap should cover, 275 * or 276 * any value <= 0 to disable resizing. 277 */ 278 @NonNull resizeBitmapArea(int area)279 public Builder resizeBitmapArea(int area) { 280 mResizeArea = area; 281 mResizeMaxDimension = -1; 282 return this; 283 } 284 285 /** 286 * Set a region of the bitmap to be used exclusively when calculating the palette. 287 * 288 * <p>This only works when the original input is a {@link Bitmap}. 289 * 290 * @param left The left side of the rectangle used for the region. 291 * @param top The top of the rectangle used for the region. 292 * @param right The right side of the rectangle used for the region. 293 * @param bottom The bottom of the rectangle used for the region. 294 */ 295 @NonNull setRegion(@x int left, @Px int top, @Px int right, @Px int bottom)296 public Builder setRegion(@Px int left, @Px int top, @Px int right, @Px int bottom) { 297 if (mBitmap != null) { 298 if (mRegion == null) mRegion = new Rect(); 299 // Set the Rect to be initially the whole Bitmap 300 mRegion.set(0, 0, mBitmap.getWidth(), mBitmap.getHeight()); 301 // Now just get the intersection with the region 302 if (!mRegion.intersect(left, top, right, bottom)) { 303 throw new IllegalArgumentException( 304 "The given region must intersect with " + "the Bitmap's dimensions."); 305 } 306 } 307 return this; 308 } 309 310 /** Clear any previously region set via {@link #setRegion(int, int, int, int)}. */ 311 @NonNull clearRegion()312 public Builder clearRegion() { 313 mRegion = null; 314 return this; 315 } 316 317 318 /** Generate and return the {@link Palette} synchronously. */ 319 @NonNull generate()320 public Palette generate() { 321 List<Swatch> swatches; 322 323 if (mBitmap != null) { 324 // We have a Bitmap so we need to use quantization to reduce the number of colors 325 326 // First we'll scale down the bitmap if needed 327 Bitmap bitmap = scaleBitmapDown(mBitmap); 328 329 Rect region = mRegion; 330 if (bitmap != mBitmap && region != null) { 331 // If we have a scaled bitmap and a selected region, we need to scale down the 332 // region to match the new scale 333 double scale = bitmap.getWidth() / (double) mBitmap.getWidth(); 334 region.left = (int) Math.floor(region.left * scale); 335 region.top = (int) Math.floor(region.top * scale); 336 region.right = Math.min((int) Math.ceil(region.right * scale), 337 bitmap.getWidth()); 338 region.bottom = Math.min((int) Math.ceil(region.bottom * scale), 339 bitmap.getHeight()); 340 } 341 342 // Now generate a quantizer from the Bitmap 343 344 mQuantizer.quantize( 345 getPixelsFromBitmap(bitmap), 346 mMaxColors); 347 // If created a new bitmap, recycle it 348 if (bitmap != mBitmap) { 349 bitmap.recycle(); 350 } 351 swatches = mQuantizer.getQuantizedColors(); 352 } else if (mSwatches != null) { 353 // Else we're using the provided swatches 354 swatches = mSwatches; 355 } else { 356 // The constructors enforce either a bitmap or swatches are present. 357 throw new AssertionError(); 358 } 359 360 // Now create a Palette instance 361 Palette p = new Palette(swatches); 362 // And make it generate itself 363 364 return p; 365 } 366 367 /** 368 * Generate the {@link Palette} asynchronously. The provided listener's {@link 369 * PaletteAsyncListener#onGenerated} method will be called with the palette when generated. 370 * 371 * @deprecated Use the standard <code>java.util.concurrent</code> or <a 372 * href="https://developer.android.com/topic/libraries/architecture/coroutines">Kotlin 373 * concurrency utilities</a> to call {@link #generate()} instead. 374 */ 375 @NonNull 376 @Deprecated generate( @onNull PaletteAsyncListener listener)377 public android.os.AsyncTask<Bitmap, Void, Palette> generate( 378 @NonNull PaletteAsyncListener listener) { 379 assert (listener != null); 380 381 return new android.os.AsyncTask<Bitmap, Void, Palette>() { 382 @Override 383 @Nullable 384 protected Palette doInBackground(Bitmap... params) { 385 try { 386 return generate(); 387 } catch (Exception e) { 388 Log.e(LOG_TAG, "Exception thrown during async generate", e); 389 return null; 390 } 391 } 392 393 @Override 394 protected void onPostExecute(@Nullable Palette colorExtractor) { 395 listener.onGenerated(colorExtractor); 396 } 397 }.executeOnExecutor(android.os.AsyncTask.THREAD_POOL_EXECUTOR, mBitmap); 398 } 399 400 private int[] getPixelsFromBitmap(Bitmap bitmap) { 401 int bitmapWidth = bitmap.getWidth(); 402 int bitmapHeight = bitmap.getHeight(); 403 int[] pixels = new int[bitmapWidth * bitmapHeight]; 404 bitmap.getPixels(pixels, 0, bitmapWidth, 0, 0, bitmapWidth, bitmapHeight); 405 406 if (mRegion == null) { 407 // If we don't have a region, return all of the pixels 408 return pixels; 409 } else { 410 // If we do have a region, lets create a subset array containing only the region's 411 // pixels 412 int regionWidth = mRegion.width(); 413 int regionHeight = mRegion.height(); 414 // pixels contains all of the pixels, so we need to iterate through each row and 415 // copy the regions pixels into a new smaller array 416 int[] subsetPixels = new int[regionWidth * regionHeight]; 417 for (int row = 0; row < regionHeight; row++) { 418 System.arraycopy( 419 pixels, 420 ((row + mRegion.top) * bitmapWidth) + mRegion.left, 421 subsetPixels, 422 row * regionWidth, 423 regionWidth); 424 } 425 return subsetPixels; 426 } 427 } 428 429 /** Scale the bitmap down as needed. */ 430 private Bitmap scaleBitmapDown(Bitmap bitmap) { 431 double scaleRatio = -1; 432 433 if (mResizeArea > 0) { 434 int bitmapArea = bitmap.getWidth() * bitmap.getHeight(); 435 if (bitmapArea > mResizeArea) { 436 scaleRatio = Math.sqrt(mResizeArea / (double) bitmapArea); 437 } 438 } else if (mResizeMaxDimension > 0) { 439 int maxDimension = Math.max(bitmap.getWidth(), bitmap.getHeight()); 440 if (maxDimension > mResizeMaxDimension) { 441 scaleRatio = mResizeMaxDimension / (double) maxDimension; 442 } 443 } 444 445 if (scaleRatio <= 0) { 446 // Scaling has been disabled or not needed so just return the Bitmap 447 return bitmap; 448 } 449 450 return Bitmap.createScaledBitmap( 451 bitmap, 452 (int) Math.ceil(bitmap.getWidth() * scaleRatio), 453 (int) Math.ceil(bitmap.getHeight() * scaleRatio), 454 false); 455 } 456 457 } 458 459 /** 460 * A Filter provides a mechanism for exercising fine-grained control over which colors 461 * are valid within a resulting {@link Palette}. 462 */ 463 public interface Filter { 464 /** 465 * Hook to allow clients to be able filter colors from resulting palette. 466 * 467 * @param rgb the color in RGB888. 468 * @param hsl HSL representation of the color. 469 * @return true if the color is allowed, false if not. 470 * @see Palette.Builder#addFilter(Palette.Filter) 471 */ 472 boolean isAllowed(int rgb, float[] hsl); 473 } 474 475 /** 476 * The default filter. 477 */ 478 static final Palette.Filter 479 DEFAULT_FILTER = new Palette.Filter() { 480 private static final float BLACK_MAX_LIGHTNESS = 0.05f; 481 private static final float WHITE_MIN_LIGHTNESS = 0.95f; 482 483 @Override 484 public boolean isAllowed(int rgb, float[] hsl) { 485 return !isWhite(hsl) && !isBlack(hsl) && !isNearRedILine(hsl); 486 } 487 488 /** 489 * @return true if the color represents a color which is close to black. 490 */ 491 private boolean isBlack(float[] hslColor) { 492 return hslColor[2] <= BLACK_MAX_LIGHTNESS; 493 } 494 495 /** 496 * @return true if the color represents a color which is close to white. 497 */ 498 private boolean isWhite(float[] hslColor) { 499 return hslColor[2] >= WHITE_MIN_LIGHTNESS; 500 } 501 502 /** 503 * @return true if the color lies close to the red side of the I line. 504 */ 505 private boolean isNearRedILine(float[] hslColor) { 506 return hslColor[0] >= 10f && hslColor[0] <= 37f && hslColor[1] <= 0.82f; 507 } 508 }; 509 } 510 511