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