• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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