blob: 55bd1cad2d4278fe36a6a3631c3cbf5bcc44c2ce [file] [log] [blame]
/*
* Copyright 2014 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package android.support.v7.graphics;
import android.graphics.Bitmap;
import android.graphics.Color;
import android.graphics.Rect;
import android.os.AsyncTask;
import android.support.annotation.ColorInt;
import android.support.annotation.Nullable;
import android.support.v4.graphics.ColorUtils;
import android.support.v4.os.AsyncTaskCompat;
import android.util.Log;
import android.util.TimingLogger;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
/**
* A helper class to extract prominent colors from an image.
* <p>
* A number of colors with different profiles are extracted from the image:
* <ul>
* <li>Vibrant</li>
* <li>Vibrant Dark</li>
* <li>Vibrant Light</li>
* <li>Muted</li>
* <li>Muted Dark</li>
* <li>Muted Light</li>
* </ul>
* These can be retrieved from the appropriate getter method.
*
* <p>
* Instances are created with a {@link Builder} which supports several options to tweak the
* generated Palette. See that class' documentation for more information.
* <p>
* Generation should always be completed on a background thread, ideally the one in
* which you load your image on. {@link Builder} supports both synchronous and asynchronous
* generation:
*
* <pre>
* // Synchronous
* Palette p = Palette.from(bitmap).generate();
*
* // Asynchronous
* Palette.from(bitmap).generate(new PaletteAsyncListener() {
* public void onGenerated(Palette p) {
* // Use generated instance
* }
* });
* </pre>
*/
public final class Palette {
/**
* Listener to be used with {@link #generateAsync(Bitmap, PaletteAsyncListener)} or
* {@link #generateAsync(Bitmap, int, PaletteAsyncListener)}
*/
public interface PaletteAsyncListener {
/**
* Called when the {@link Palette} has been generated.
*/
void onGenerated(Palette palette);
}
private static final int DEFAULT_RESIZE_BITMAP_MAX_DIMENSION = 192;
private static final int DEFAULT_CALCULATE_NUMBER_COLORS = 16;
private static final float MIN_CONTRAST_TITLE_TEXT = 3.0f;
private static final float MIN_CONTRAST_BODY_TEXT = 4.5f;
private static final String LOG_TAG = "Palette";
private static final boolean LOG_TIMINGS = false;
/**
* Start generating a {@link Palette} with the returned {@link Builder} instance.
*/
public static Builder from(Bitmap bitmap) {
return new Builder(bitmap);
}
/**
* Generate a {@link Palette} from the pre-generated list of {@link Palette.Swatch} swatches.
* This is useful for testing, or if you want to resurrect a {@link Palette} instance from a
* list of swatches. Will return null if the {@code swatches} is null.
*/
public static Palette from(List<Swatch> swatches) {
return new Builder(swatches).generate();
}
/**
* @deprecated Use {@link Builder} to generate the Palette.
*/
@Deprecated
public static Palette generate(Bitmap bitmap) {
return from(bitmap).generate();
}
/**
* @deprecated Use {@link Builder} to generate the Palette.
*/
@Deprecated
public static Palette generate(Bitmap bitmap, int numColors) {
return from(bitmap).maximumColorCount(numColors).generate();
}
/**
* @deprecated Use {@link Builder} to generate the Palette.
*/
@Deprecated
public static AsyncTask<Bitmap, Void, Palette> generateAsync(
Bitmap bitmap, PaletteAsyncListener listener) {
return from(bitmap).generate(listener);
}
/**
* @deprecated Use {@link Builder} to generate the Palette.
*/
@Deprecated
public static AsyncTask<Bitmap, Void, Palette> generateAsync(
final Bitmap bitmap, final int numColors, final PaletteAsyncListener listener) {
return from(bitmap).maximumColorCount(numColors).generate(listener);
}
private final List<Swatch> mSwatches;
private final Generator mGenerator;
private Palette(List<Swatch> swatches, Generator generator) {
mSwatches = swatches;
mGenerator = generator;
}
/**
* Returns all of the swatches which make up the palette.
*/
public List<Swatch> getSwatches() {
return Collections.unmodifiableList(mSwatches);
}
/**
* Returns the most vibrant swatch in the palette. Might be null.
*/
@Nullable
public Swatch getVibrantSwatch() {
return mGenerator.getVibrantSwatch();
}
/**
* Returns a light and vibrant swatch from the palette. Might be null.
*/
@Nullable
public Swatch getLightVibrantSwatch() {
return mGenerator.getLightVibrantSwatch();
}
/**
* Returns a dark and vibrant swatch from the palette. Might be null.
*/
@Nullable
public Swatch getDarkVibrantSwatch() {
return mGenerator.getDarkVibrantSwatch();
}
/**
* Returns a muted swatch from the palette. Might be null.
*/
@Nullable
public Swatch getMutedSwatch() {
return mGenerator.getMutedSwatch();
}
/**
* Returns a muted and light swatch from the palette. Might be null.
*/
@Nullable
public Swatch getLightMutedSwatch() {
return mGenerator.getLightMutedSwatch();
}
/**
* Returns a muted and dark swatch from the palette. Might be null.
*/
@Nullable
public Swatch getDarkMutedSwatch() {
return mGenerator.getDarkMutedSwatch();
}
/**
* Returns the most vibrant color in the palette as an RGB packed int.
*
* @param defaultColor value to return if the swatch isn't available
*/
@ColorInt
public int getVibrantColor(@ColorInt int defaultColor) {
Swatch swatch = getVibrantSwatch();
return swatch != null ? swatch.getRgb() : defaultColor;
}
/**
* Returns a light and vibrant color from the palette as an RGB packed int.
*
* @param defaultColor value to return if the swatch isn't available
*/
@ColorInt
public int getLightVibrantColor(@ColorInt int defaultColor) {
Swatch swatch = getLightVibrantSwatch();
return swatch != null ? swatch.getRgb() : defaultColor;
}
/**
* Returns a dark and vibrant color from the palette as an RGB packed int.
*
* @param defaultColor value to return if the swatch isn't available
*/
@ColorInt
public int getDarkVibrantColor(@ColorInt int defaultColor) {
Swatch swatch = getDarkVibrantSwatch();
return swatch != null ? swatch.getRgb() : defaultColor;
}
/**
* Returns a muted color from the palette as an RGB packed int.
*
* @param defaultColor value to return if the swatch isn't available
*/
@ColorInt
public int getMutedColor(@ColorInt int defaultColor) {
Swatch swatch = getMutedSwatch();
return swatch != null ? swatch.getRgb() : defaultColor;
}
/**
* Returns a muted and light color from the palette as an RGB packed int.
*
* @param defaultColor value to return if the swatch isn't available
*/
@ColorInt
public int getLightMutedColor(@ColorInt int defaultColor) {
Swatch swatch = getLightMutedSwatch();
return swatch != null ? swatch.getRgb() : defaultColor;
}
/**
* Returns a muted and dark color from the palette as an RGB packed int.
*
* @param defaultColor value to return if the swatch isn't available
*/
@ColorInt
public int getDarkMutedColor(@ColorInt int defaultColor) {
Swatch swatch = getDarkMutedSwatch();
return swatch != null ? swatch.getRgb() : defaultColor;
}
/**
* Scale the bitmap down so that it's largest dimension is {@code targetMaxDimension}.
* If {@code bitmap} is smaller than this, then it is returned.
*/
private static Bitmap scaleBitmapDown(Bitmap bitmap, final int targetMaxDimension) {
final int maxDimension = Math.max(bitmap.getWidth(), bitmap.getHeight());
if (maxDimension <= targetMaxDimension) {
// If the bitmap is small enough already, just return it
return bitmap;
}
final float scaleRatio = targetMaxDimension / (float) maxDimension;
return Bitmap.createScaledBitmap(bitmap,
Math.round(bitmap.getWidth() * scaleRatio),
Math.round(bitmap.getHeight() * scaleRatio),
false);
}
/**
* Represents a color swatch generated from an image's palette. The RGB color can be retrieved
* by calling {@link #getRgb()}.
*/
public static final class Swatch {
private final int mRed, mGreen, mBlue;
private final int mRgb;
private final int mPopulation;
private boolean mGeneratedTextColors;
private int mTitleTextColor;
private int mBodyTextColor;
private float[] mHsl;
public Swatch(@ColorInt int color, int population) {
mRed = Color.red(color);
mGreen = Color.green(color);
mBlue = Color.blue(color);
mRgb = color;
mPopulation = population;
}
Swatch(int red, int green, int blue, int population) {
mRed = red;
mGreen = green;
mBlue = blue;
mRgb = Color.rgb(red, green, blue);
mPopulation = population;
}
/**
* @return this swatch's RGB color value
*/
@ColorInt
public int getRgb() {
return mRgb;
}
/**
* Return this swatch's HSL values.
* hsv[0] is Hue [0 .. 360)
* hsv[1] is Saturation [0...1]
* hsv[2] is Lightness [0...1]
*/
public float[] getHsl() {
if (mHsl == null) {
mHsl = new float[3];
ColorUtils.RGBToHSL(mRed, mGreen, mBlue, mHsl);
}
return mHsl;
}
/**
* @return the number of pixels represented by this swatch
*/
public int getPopulation() {
return mPopulation;
}
/**
* Returns an appropriate color to use for any 'title' text which is displayed over this
* {@link Swatch}'s color. This color is guaranteed to have sufficient contrast.
*/
@ColorInt
public int getTitleTextColor() {
ensureTextColorsGenerated();
return mTitleTextColor;
}
/**
* Returns an appropriate color to use for any 'body' text which is displayed over this
* {@link Swatch}'s color. This color is guaranteed to have sufficient contrast.
*/
@ColorInt
public int getBodyTextColor() {
ensureTextColorsGenerated();
return mBodyTextColor;
}
private void ensureTextColorsGenerated() {
if (!mGeneratedTextColors) {
// First check white, as most colors will be dark
final int lightBodyAlpha = ColorUtils.calculateMinimumAlpha(
Color.WHITE, mRgb, MIN_CONTRAST_BODY_TEXT);
final int lightTitleAlpha = ColorUtils.calculateMinimumAlpha(
Color.WHITE, mRgb, MIN_CONTRAST_TITLE_TEXT);
if (lightBodyAlpha != -1 && lightTitleAlpha != -1) {
// If we found valid light values, use them and return
mBodyTextColor = ColorUtils.setAlphaComponent(Color.WHITE, lightBodyAlpha);
mTitleTextColor = ColorUtils.setAlphaComponent(Color.WHITE, lightTitleAlpha);
mGeneratedTextColors = true;
return;
}
final int darkBodyAlpha = ColorUtils.calculateMinimumAlpha(
Color.BLACK, mRgb, MIN_CONTRAST_BODY_TEXT);
final int darkTitleAlpha = ColorUtils.calculateMinimumAlpha(
Color.BLACK, mRgb, MIN_CONTRAST_TITLE_TEXT);
if (darkBodyAlpha != -1 && darkBodyAlpha != -1) {
// If we found valid dark values, use them and return
mBodyTextColor = ColorUtils.setAlphaComponent(Color.BLACK, darkBodyAlpha);
mTitleTextColor = ColorUtils.setAlphaComponent(Color.BLACK, darkTitleAlpha);
mGeneratedTextColors = true;
return;
}
// If we reach here then we can not find title and body values which use the same
// lightness, we need to use mismatched values
mBodyTextColor = lightBodyAlpha != -1
? ColorUtils.setAlphaComponent(Color.WHITE, lightBodyAlpha)
: ColorUtils.setAlphaComponent(Color.BLACK, darkBodyAlpha);
mTitleTextColor = lightTitleAlpha != -1
? ColorUtils.setAlphaComponent(Color.WHITE, lightTitleAlpha)
: ColorUtils.setAlphaComponent(Color.BLACK, darkTitleAlpha);
mGeneratedTextColors = true;
}
}
@Override
public String toString() {
return new StringBuilder(getClass().getSimpleName())
.append(" [RGB: #").append(Integer.toHexString(getRgb())).append(']')
.append(" [HSL: ").append(Arrays.toString(getHsl())).append(']')
.append(" [Population: ").append(mPopulation).append(']')
.append(" [Title Text: #").append(Integer.toHexString(getTitleTextColor()))
.append(']')
.append(" [Body Text: #").append(Integer.toHexString(getBodyTextColor()))
.append(']').toString();
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
Swatch swatch = (Swatch) o;
return mPopulation == swatch.mPopulation && mRgb == swatch.mRgb;
}
@Override
public int hashCode() {
return 31 * mRgb + mPopulation;
}
}
/**
* Builder class for generating {@link Palette} instances.
*/
public static final class Builder {
private final List<Swatch> mSwatches;
private final Bitmap mBitmap;
private int mMaxColors = DEFAULT_CALCULATE_NUMBER_COLORS;
private int mResizeMaxDimension = DEFAULT_RESIZE_BITMAP_MAX_DIMENSION;
private final List<Filter> mFilters = new ArrayList<>();
private Rect mRegion;
private Generator mGenerator;
/**
* Construct a new {@link Builder} using a source {@link Bitmap}
*/
public Builder(Bitmap bitmap) {
if (bitmap == null || bitmap.isRecycled()) {
throw new IllegalArgumentException("Bitmap is not valid");
}
mFilters.add(DEFAULT_FILTER);
mBitmap = bitmap;
mSwatches = null;
}
/**
* Construct a new {@link Builder} using a list of {@link Swatch} instances.
* Typically only used for testing.
*/
public Builder(List<Swatch> swatches) {
if (swatches == null || swatches.isEmpty()) {
throw new IllegalArgumentException("List of Swatches is not valid");
}
mFilters.add(DEFAULT_FILTER);
mSwatches = swatches;
mBitmap = null;
}
/**
* Set the {@link Generator} to use when generating the {@link Palette}. If this is called
* with {@code null} then the default generator will be used.
*/
Builder generator(Generator generator) {
mGenerator = generator;
return this;
}
/**
* Set the maximum number of colors to use in the quantization step when using a
* {@link android.graphics.Bitmap} as the source.
* <p>
* Good values for depend on the source image type. For landscapes, good values are in
* the range 10-16. For images which are largely made up of people's faces then this
* value should be increased to ~24.
*/
public Builder maximumColorCount(int colors) {
mMaxColors = colors;
return this;
}
/**
* Set the resize value when using a {@link android.graphics.Bitmap} as the source.
* If the bitmap's largest dimension is greater than the value specified, then the bitmap
* will be resized so that it's largest dimension matches {@code maxDimension}. If the
* bitmap is smaller or equal, the original is used as-is.
* <p>
* This value has a large effect on the processing time. The larger the resized image is,
* the greater time it will take to generate the palette. The smaller the image is, the
* more detail is lost in the resulting image and thus less precision for color selection.
*/
public Builder resizeBitmapSize(int maxDimension) {
mResizeMaxDimension = maxDimension;
return this;
}
/**
* Clear all added filters. This includes any default filters added automatically by
* {@link Palette}.
*/
public Builder clearFilters() {
mFilters.clear();
return this;
}
/**
* Add a filter to be able to have fine grained controlled over the colors which are
* allowed in the resulting palette.
*
* @param filter filter to add.
*/
public Builder addFilter(Filter filter) {
if (filter != null) {
mFilters.add(filter);
}
return this;
}
/**
* Set a region of the bitmap to be used exclusively when calculating the palette.
* <p>This only works when the original input is a {@link Bitmap}.</p>
*
* @param left The left side of the rectangle used for the region.
* @param top The top of the rectangle used for the region.
* @param right The right side of the rectangle used for the region.
* @param bottom The bottom of the rectangle used for the region.
*/
public Builder setRegion(int left, int top, int right, int bottom) {
if (mBitmap != null) {
if (mRegion == null) mRegion = new Rect();
// Set the Rect to be initially the whole Bitmap
mRegion.set(0, 0, mBitmap.getWidth(), mBitmap.getHeight());
// Now just get the intersection with the region
if (!mRegion.intersect(left, top, right, bottom)) {
throw new IllegalArgumentException("The given region must intersect with "
+ "the Bitmap's dimensions.");
}
}
return this;
}
/**
* Clear any previously region set via {@link #setRegion(int, int, int, int)}.
*/
public Builder clearRegion() {
mRegion = null;
return this;
}
/**
* Generate and return the {@link Palette} synchronously.
*/
public Palette generate() {
final TimingLogger logger = LOG_TIMINGS
? new TimingLogger(LOG_TAG, "Generation")
: null;
List<Swatch> swatches;
if (mBitmap != null) {
// We have a Bitmap so we need to quantization to reduce the number of colors
if (mResizeMaxDimension <= 0) {
throw new IllegalArgumentException(
"Minimum dimension size for resizing should should be >= 1");
}
// First we'll scale down the bitmap so it's largest dimension is as specified
final Bitmap bitmap = scaleBitmapDown(mBitmap, mResizeMaxDimension);
if (logger != null) {
logger.addSplit("Processed Bitmap");
}
final Rect region = mRegion;
if (bitmap != mBitmap && region != null) {
// If we have a scaled bitmap and a selected region, we need to scale down the
// region to match the new scale
final float scale = bitmap.getWidth() / (float) mBitmap.getWidth();
region.left = (int) Math.floor(region.left * scale);
region.top = (int) Math.floor(region.top * scale);
region.right = (int) Math.ceil(region.right * scale);
region.bottom = (int) Math.ceil(region.bottom * scale);
}
// Now generate a quantizer from the Bitmap
final ColorCutQuantizer quantizer = new ColorCutQuantizer(
getPixelsFromBitmap(bitmap),
mMaxColors,
mFilters.isEmpty() ? null : mFilters.toArray(new Filter[mFilters.size()]));
// If created a new bitmap, recycle it
if (bitmap != mBitmap) {
bitmap.recycle();
}
swatches = quantizer.getQuantizedColors();
if (logger != null) {
logger.addSplit("Color quantization completed");
}
} else {
// Else we're using the provided swatches
swatches = mSwatches;
}
// If we haven't been provided with a generator, use the default
if (mGenerator == null) {
mGenerator = new DefaultGenerator();
}
// Now call let the Generator do it's thing
mGenerator.generate(swatches);
if (logger != null) {
logger.addSplit("Generator.generate() completed");
}
// Now create a Palette instance
Palette p = new Palette(swatches, mGenerator);
if (logger != null) {
logger.addSplit("Created Palette");
logger.dumpToLog();
}
return p;
}
/**
* Generate the {@link Palette} asynchronously. The provided listener's
* {@link PaletteAsyncListener#onGenerated} method will be called with the palette when
* generated.
*/
public AsyncTask<Bitmap, Void, Palette> generate(final PaletteAsyncListener listener) {
if (listener == null) {
throw new IllegalArgumentException("listener can not be null");
}
return AsyncTaskCompat.executeParallel(
new AsyncTask<Bitmap, Void, Palette>() {
@Override
protected Palette doInBackground(Bitmap... params) {
try {
return generate();
} catch (Exception e) {
Log.e(LOG_TAG, "Exception thrown during async generate", e);
return null;
}
}
@Override
protected void onPostExecute(Palette colorExtractor) {
listener.onGenerated(colorExtractor);
}
}, mBitmap);
}
private int[] getPixelsFromBitmap(Bitmap bitmap) {
final int bitmapWidth = bitmap.getWidth();
final int bitmapHeight = bitmap.getHeight();
final int[] pixels = new int[bitmapWidth * bitmapHeight];
if (mRegion == null) {
// If we don't have a region, return all of the pixels
bitmap.getPixels(pixels, 0, bitmapWidth, 0, 0, bitmapWidth, bitmapHeight);
return pixels;
} else {
// If we do have a region, lets create a subset array containing only the region's
// pixels
final int regionWidth = mRegion.width();
final int regionHeight = mRegion.height();
// First read the pixels within the region
bitmap.getPixels(pixels, 0, bitmapWidth, mRegion.left, mRegion.top,
regionWidth, regionHeight);
// pixels now contains all of the pixels, but not packed together. We need to
// iterate through each row and copy them into a new smaller array
final int[] subsetPixels = new int[regionWidth * mRegion.height()];
for (int row = mRegion.top; row < mRegion.bottom; row++) {
System.arraycopy(pixels, (row * bitmapWidth) + mRegion.left,
subsetPixels, row * regionWidth, regionWidth);
}
return subsetPixels;
}
}
}
static abstract class Generator {
/**
* This method will be called with the {@link Palette.Swatch} that represent an image.
* You should process this list so that you have appropriate values when the other methods in
* class are called.
* <p>
* This method will probably be called on a background thread.
*/
public abstract void generate(List<Palette.Swatch> swatches);
/**
* Return the most vibrant {@link Palette.Swatch}
*/
public Palette.Swatch getVibrantSwatch() {
return null;
}
/**
* Return a light and vibrant {@link Palette.Swatch}
*/
public Palette.Swatch getLightVibrantSwatch() {
return null;
}
/**
* Return a dark and vibrant {@link Palette.Swatch}
*/
public Palette.Swatch getDarkVibrantSwatch() {
return null;
}
/**
* Return a muted {@link Palette.Swatch}
*/
public Palette.Swatch getMutedSwatch() {
return null;
}
/**
* Return a muted and light {@link Palette.Swatch}
*/
public Palette.Swatch getLightMutedSwatch() {
return null;
}
/**
* Return a muted and dark {@link Palette.Swatch}
*/
public Palette.Swatch getDarkMutedSwatch() {
return null;
}
}
/**
* A Filter provides a mechanism for exercising fine-grained control over which colors
* are valid within a resulting {@link Palette}.
*/
public interface Filter {
/**
* Hook to allow clients to be able filter colors from resulting palette.
*
* @param rgb the color in RGB888.
* @param hsl HSL representation of the color.
*
* @return true if the color is allowed, false if not.
*
* @see Builder#addFilter(Filter)
*/
boolean isAllowed(int rgb, float[] hsl);
}
/**
* The default filter.
*/
private static final Filter DEFAULT_FILTER = new Filter() {
private static final float BLACK_MAX_LIGHTNESS = 0.05f;
private static final float WHITE_MIN_LIGHTNESS = 0.95f;
@Override
public boolean isAllowed(int rgb, float[] hsl) {
return !isWhite(hsl) && !isBlack(hsl) && !isNearRedILine(hsl);
}
/**
* @return true if the color represents a color which is close to black.
*/
private boolean isBlack(float[] hslColor) {
return hslColor[2] <= BLACK_MAX_LIGHTNESS;
}
/**
* @return true if the color represents a color which is close to white.
*/
private boolean isWhite(float[] hslColor) {
return hslColor[2] >= WHITE_MIN_LIGHTNESS;
}
/**
* @return true if the color lies close to the red side of the I line.
*/
private boolean isNearRedILine(float[] hslColor) {
return hslColor[0] >= 10f && hslColor[0] <= 37f && hslColor[1] <= 0.82f;
}
};
}