/* * 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.Color; import android.support.v4.graphics.ColorUtils; import android.support.v7.graphics.Palette.Swatch; import android.util.TimingLogger; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Comparator; import java.util.List; import java.util.PriorityQueue; /** * An color quantizer based on the Median-cut algorithm, but optimized for picking out distinct * colors rather than representation colors. * * The color space is represented as a 3-dimensional cube with each dimension being an RGB * component. The cube is then repeatedly divided until we have reduced the color space to the * requested number of colors. An average color is then generated from each cube. * * What makes this different to median-cut is that median-cut divided cubes so that all of the cubes * have roughly the same population, where this quantizer divides boxes based on their color volume. * This means that the color space is divided into distinct colors, rather than representative * colors. */ final class ColorCutQuantizer { private static final String LOG_TAG = "ColorCutQuantizer"; private static final boolean LOG_TIMINGS = false; private static final int COMPONENT_RED = -3; private static final int COMPONENT_GREEN = -2; private static final int COMPONENT_BLUE = -1; private static final int QUANTIZE_WORD_WIDTH = 5; private static final int QUANTIZE_WORD_MASK = (1 << QUANTIZE_WORD_WIDTH) - 1; final int[] mColors; final int[] mHistogram; final List mQuantizedColors; final TimingLogger mTimingLogger; final Palette.Filter[] mFilters; private final float[] mTempHsl = new float[3]; /** * Constructor. * * @param pixels histogram representing an image's pixel data * @param maxColors The maximum number of colors that should be in the result palette. * @param filters Set of filters to use in the quantization stage */ ColorCutQuantizer(final int[] pixels, final int maxColors, final Palette.Filter[] filters) { mTimingLogger = LOG_TIMINGS ? new TimingLogger(LOG_TAG, "Creation") : null; mFilters = filters; final int[] hist = mHistogram = new int[1 << (QUANTIZE_WORD_WIDTH * 3)]; for (int i = 0; i < pixels.length; i++) { final int quantizedColor = quantizeFromRgb888(pixels[i]); // Now update the pixel value to the quantized value pixels[i] = quantizedColor; // And update the histogram hist[quantizedColor]++; } if (LOG_TIMINGS) { mTimingLogger.addSplit("Histogram created"); } // Now let's count the number of distinct colors int distinctColorCount = 0; for (int color = 0; color < hist.length; color++) { if (hist[color] > 0 && shouldIgnoreColor(color)) { // If we should ignore the color, set the population to 0 hist[color] = 0; } if (hist[color] > 0) { // If the color has population, increase the distinct color count distinctColorCount++; } } if (LOG_TIMINGS) { mTimingLogger.addSplit("Filtered colors and distinct colors counted"); } // Now lets go through create an array consisting of only distinct colors final int[] colors = mColors = new int[distinctColorCount]; int distinctColorIndex = 0; for (int color = 0; color < hist.length; color++) { if (hist[color] > 0) { colors[distinctColorIndex++] = color; } } if (LOG_TIMINGS) { mTimingLogger.addSplit("Distinct colors copied into array"); } if (distinctColorCount <= maxColors) { // The image has fewer colors than the maximum requested, so just return the colors mQuantizedColors = new ArrayList<>(); for (int color : colors) { mQuantizedColors.add(new Swatch(approximateToRgb888(color), hist[color])); } if (LOG_TIMINGS) { mTimingLogger.addSplit("Too few colors present. Copied to Swatches"); mTimingLogger.dumpToLog(); } } else { // We need use quantization to reduce the number of colors mQuantizedColors = quantizePixels(maxColors); if (LOG_TIMINGS) { mTimingLogger.addSplit("Quantized colors computed"); mTimingLogger.dumpToLog(); } } } /** * @return the list of quantized colors */ List getQuantizedColors() { return mQuantizedColors; } private List quantizePixels(int maxColors) { // Create the priority queue which is sorted by volume descending. This means we always // split the largest box in the queue final PriorityQueue pq = new PriorityQueue<>(maxColors, VBOX_COMPARATOR_VOLUME); // To start, offer a box which contains all of the colors pq.offer(new Vbox(0, mColors.length - 1)); // Now go through the boxes, splitting them until we have reached maxColors or there are no // more boxes to split splitBoxes(pq, maxColors); // Finally, return the average colors of the color boxes return generateAverageColors(pq); } /** * Iterate through the {@link java.util.Queue}, popping * {@link ColorCutQuantizer.Vbox} objects from the queue * and splitting them. Once split, the new box and the remaining box are offered back to the * queue. * * @param queue {@link java.util.PriorityQueue} to poll for boxes * @param maxSize Maximum amount of boxes to split */ private void splitBoxes(final PriorityQueue queue, final int maxSize) { while (queue.size() < maxSize) { final Vbox vbox = queue.poll(); if (vbox != null && vbox.canSplit()) { // First split the box, and offer the result queue.offer(vbox.splitBox()); if (LOG_TIMINGS) { mTimingLogger.addSplit("Box split"); } // Then offer the box back queue.offer(vbox); } else { if (LOG_TIMINGS) { mTimingLogger.addSplit("All boxes split"); } // If we get here then there are no more boxes to split, so return return; } } } private List generateAverageColors(Collection vboxes) { ArrayList colors = new ArrayList<>(vboxes.size()); for (Vbox vbox : vboxes) { Swatch swatch = vbox.getAverageColor(); if (!shouldIgnoreColor(swatch)) { // As we're averaging a color box, we can still get colors which we do not want, so // we check again here colors.add(swatch); } } return colors; } /** * Represents a tightly fitting box around a color space. */ private class Vbox { // lower and upper index are inclusive private int mLowerIndex; private int mUpperIndex; // Population of colors within this box private int mPopulation; private int mMinRed, mMaxRed; private int mMinGreen, mMaxGreen; private int mMinBlue, mMaxBlue; Vbox(int lowerIndex, int upperIndex) { mLowerIndex = lowerIndex; mUpperIndex = upperIndex; fitBox(); } final int getVolume() { return (mMaxRed - mMinRed + 1) * (mMaxGreen - mMinGreen + 1) * (mMaxBlue - mMinBlue + 1); } final boolean canSplit() { return getColorCount() > 1; } final int getColorCount() { return 1 + mUpperIndex - mLowerIndex; } /** * Recomputes the boundaries of this box to tightly fit the colors within the box. */ final void fitBox() { final int[] colors = mColors; final int[] hist = mHistogram; // Reset the min and max to opposite values int minRed, minGreen, minBlue; minRed = minGreen = minBlue = Integer.MAX_VALUE; int maxRed, maxGreen, maxBlue; maxRed = maxGreen = maxBlue = Integer.MIN_VALUE; int count = 0; for (int i = mLowerIndex; i <= mUpperIndex; i++) { final int color = colors[i]; count += hist[color]; final int r = quantizedRed(color); final int g = quantizedGreen(color); final int b = quantizedBlue(color); if (r > maxRed) { maxRed = r; } if (r < minRed) { minRed = r; } if (g > maxGreen) { maxGreen = g; } if (g < minGreen) { minGreen = g; } if (b > maxBlue) { maxBlue = b; } if (b < minBlue) { minBlue = b; } } mMinRed = minRed; mMaxRed = maxRed; mMinGreen = minGreen; mMaxGreen = maxGreen; mMinBlue = minBlue; mMaxBlue = maxBlue; mPopulation = count; } /** * Split this color box at the mid-point along it's longest dimension * * @return the new ColorBox */ final Vbox splitBox() { if (!canSplit()) { throw new IllegalStateException("Can not split a box with only 1 color"); } // find median along the longest dimension final int splitPoint = findSplitPoint(); Vbox newBox = new Vbox(splitPoint + 1, mUpperIndex); // Now change this box's upperIndex and recompute the color boundaries mUpperIndex = splitPoint; fitBox(); return newBox; } /** * @return the dimension which this box is largest in */ final int getLongestColorDimension() { final int redLength = mMaxRed - mMinRed; final int greenLength = mMaxGreen - mMinGreen; final int blueLength = mMaxBlue - mMinBlue; if (redLength >= greenLength && redLength >= blueLength) { return COMPONENT_RED; } else if (greenLength >= redLength && greenLength >= blueLength) { return COMPONENT_GREEN; } else { return COMPONENT_BLUE; } } /** * Finds the point within this box's lowerIndex and upperIndex index of where to split. * * This is calculated by finding the longest color dimension, and then sorting the * sub-array based on that dimension value in each color. The colors are then iterated over * until a color is found with at least the midpoint of the whole box's dimension midpoint. * * @return the index of the colors array to split from */ final int findSplitPoint() { final int longestDimension = getLongestColorDimension(); final int[] colors = mColors; final int[] hist = mHistogram; // We need to sort the colors in this box based on the longest color dimension. // As we can't use a Comparator to define the sort logic, we modify each color so that // it's most significant is the desired dimension modifySignificantOctet(colors, longestDimension, mLowerIndex, mUpperIndex); // Now sort... Arrays.sort uses a exclusive toIndex so we need to add 1 Arrays.sort(colors, mLowerIndex, mUpperIndex + 1); // Now revert all of the colors so that they are packed as RGB again modifySignificantOctet(colors, longestDimension, mLowerIndex, mUpperIndex); final int midPoint = mPopulation / 2; for (int i = mLowerIndex, count = 0; i <= mUpperIndex; i++) { count += hist[colors[i]]; if (count >= midPoint) { return i; } } return mLowerIndex; } /** * @return the average color of this box. */ final Swatch getAverageColor() { final int[] colors = mColors; final int[] hist = mHistogram; int redSum = 0; int greenSum = 0; int blueSum = 0; int totalPopulation = 0; for (int i = mLowerIndex; i <= mUpperIndex; i++) { final int color = colors[i]; final int colorPopulation = hist[color]; totalPopulation += colorPopulation; redSum += colorPopulation * quantizedRed(color); greenSum += colorPopulation * quantizedGreen(color); blueSum += colorPopulation * quantizedBlue(color); } final int redMean = Math.round(redSum / (float) totalPopulation); final int greenMean = Math.round(greenSum / (float) totalPopulation); final int blueMean = Math.round(blueSum / (float) totalPopulation); return new Swatch(approximateToRgb888(redMean, greenMean, blueMean), totalPopulation); } } /** * Modify the significant octet in a packed color int. Allows sorting based on the value of a * single color component. This relies on all components being the same word size. * * @see Vbox#findSplitPoint() */ private static void modifySignificantOctet(final int[] a, final int dimension, final int lower, final int upper) { switch (dimension) { case COMPONENT_RED: // Already in RGB, no need to do anything break; case COMPONENT_GREEN: // We need to do a RGB to GRB swap, or vice-versa for (int i = lower; i <= upper; i++) { final int color = a[i]; a[i] = quantizedGreen(color) << (QUANTIZE_WORD_WIDTH + QUANTIZE_WORD_WIDTH) | quantizedRed(color) << QUANTIZE_WORD_WIDTH | quantizedBlue(color); } break; case COMPONENT_BLUE: // We need to do a RGB to BGR swap, or vice-versa for (int i = lower; i <= upper; i++) { final int color = a[i]; a[i] = quantizedBlue(color) << (QUANTIZE_WORD_WIDTH + QUANTIZE_WORD_WIDTH) | quantizedGreen(color) << QUANTIZE_WORD_WIDTH | quantizedRed(color); } break; } } private boolean shouldIgnoreColor(int color565) { final int rgb = approximateToRgb888(color565); ColorUtils.colorToHSL(rgb, mTempHsl); return shouldIgnoreColor(rgb, mTempHsl); } private boolean shouldIgnoreColor(Swatch color) { return shouldIgnoreColor(color.getRgb(), color.getHsl()); } private boolean shouldIgnoreColor(int rgb, float[] hsl) { if (mFilters != null && mFilters.length > 0) { for (int i = 0, count = mFilters.length; i < count; i++) { if (!mFilters[i].isAllowed(rgb, hsl)) { return true; } } } return false; } /** * Comparator which sorts {@link Vbox} instances based on their volume, in descending order */ private static final Comparator VBOX_COMPARATOR_VOLUME = new Comparator() { @Override public int compare(Vbox lhs, Vbox rhs) { return rhs.getVolume() - lhs.getVolume(); } }; /** * Quantized a RGB888 value to have a word width of {@value #QUANTIZE_WORD_WIDTH}. */ private static int quantizeFromRgb888(int color) { int r = modifyWordWidth(Color.red(color), 8, QUANTIZE_WORD_WIDTH); int g = modifyWordWidth(Color.green(color), 8, QUANTIZE_WORD_WIDTH); int b = modifyWordWidth(Color.blue(color), 8, QUANTIZE_WORD_WIDTH); return r << (QUANTIZE_WORD_WIDTH + QUANTIZE_WORD_WIDTH) | g << QUANTIZE_WORD_WIDTH | b; } /** * Quantized RGB888 values to have a word width of {@value #QUANTIZE_WORD_WIDTH}. */ private static int approximateToRgb888(int r, int g, int b) { return Color.rgb(modifyWordWidth(r, QUANTIZE_WORD_WIDTH, 8), modifyWordWidth(g, QUANTIZE_WORD_WIDTH, 8), modifyWordWidth(b, QUANTIZE_WORD_WIDTH, 8)); } private static int approximateToRgb888(int color) { return approximateToRgb888(quantizedRed(color), quantizedGreen(color), quantizedBlue(color)); } /** * @return red component of the quantized color */ private static int quantizedRed(int color) { return (color >> (QUANTIZE_WORD_WIDTH + QUANTIZE_WORD_WIDTH)) & QUANTIZE_WORD_MASK; } /** * @return green component of a quantized color */ private static int quantizedGreen(int color) { return (color >> QUANTIZE_WORD_WIDTH) & QUANTIZE_WORD_MASK; } /** * @return blue component of a quantized color */ private static int quantizedBlue(int color) { return color & QUANTIZE_WORD_MASK; } private static int modifyWordWidth(int value, int currentWidth, int targetWidth) { final int newValue; if (targetWidth > currentWidth) { // If we're approximating up in word width, we'll use scaling to approximate the // new value newValue = value * ((1 << targetWidth) - 1) / ((1 << currentWidth) - 1); } else { // Else, we will just shift and keep the MSB newValue = value >> (currentWidth - targetWidth); } return newValue & ((1 << targetWidth) - 1); } }