Home | History | Annotate | Download | only in types
      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.colorextraction.types;
     18 
     19 import android.annotation.NonNull;
     20 import android.annotation.Nullable;
     21 import android.app.WallpaperColors;
     22 import android.content.Context;
     23 import android.graphics.Color;
     24 import android.util.Log;
     25 import android.util.MathUtils;
     26 import android.util.Range;
     27 
     28 import com.android.internal.R;
     29 import com.android.internal.annotations.VisibleForTesting;
     30 import com.android.internal.colorextraction.ColorExtractor.GradientColors;
     31 import com.android.internal.graphics.ColorUtils;
     32 
     33 import org.xmlpull.v1.XmlPullParser;
     34 import org.xmlpull.v1.XmlPullParserException;
     35 
     36 import java.io.IOException;
     37 import java.util.ArrayList;
     38 import java.util.Arrays;
     39 import java.util.List;
     40 
     41 /**
     42  * Implementation of tonal color extraction
     43  */
     44 public class Tonal implements ExtractionType {
     45     private static final String TAG = "Tonal";
     46 
     47     // Used for tonal palette fitting
     48     private static final float FIT_WEIGHT_H = 1.0f;
     49     private static final float FIT_WEIGHT_S = 1.0f;
     50     private static final float FIT_WEIGHT_L = 10.0f;
     51 
     52     private static final boolean DEBUG = true;
     53 
     54     public static final int MAIN_COLOR_LIGHT = 0xffe0e0e0;
     55     public static final int SECONDARY_COLOR_LIGHT = 0xff9e9e9e;
     56     public static final int MAIN_COLOR_DARK = 0xff212121;
     57     public static final int SECONDARY_COLOR_DARK = 0xff000000;
     58 
     59     private final TonalPalette mGreyPalette;
     60     private final ArrayList<TonalPalette> mTonalPalettes;
     61     private final ArrayList<ColorRange> mBlacklistedColors;
     62 
     63     // Temporary variable to avoid allocations
     64     private float[] mTmpHSL = new float[3];
     65 
     66     public Tonal(Context context) {
     67 
     68         ConfigParser parser = new ConfigParser(context);
     69         mTonalPalettes = parser.getTonalPalettes();
     70         mBlacklistedColors = parser.getBlacklistedColors();
     71 
     72         mGreyPalette = mTonalPalettes.get(0);
     73         mTonalPalettes.remove(0);
     74     }
     75 
     76     /**
     77      * Grab colors from WallpaperColors and set them into GradientColors.
     78      * Also applies the default gradient in case extraction fails.
     79      *
     80      * @param inWallpaperColors Input.
     81      * @param outColorsNormal Colors for normal theme.
     82      * @param outColorsDark Colors for dar theme.
     83      * @param outColorsExtraDark Colors for extra dark theme.
     84      */
     85     public void extractInto(@Nullable WallpaperColors inWallpaperColors,
     86             @NonNull GradientColors outColorsNormal, @NonNull GradientColors outColorsDark,
     87             @NonNull GradientColors outColorsExtraDark) {
     88         boolean success = runTonalExtraction(inWallpaperColors, outColorsNormal, outColorsDark,
     89                 outColorsExtraDark);
     90         if (!success) {
     91             applyFallback(inWallpaperColors, outColorsNormal, outColorsDark, outColorsExtraDark);
     92         }
     93     }
     94 
     95     /**
     96      * Grab colors from WallpaperColors and set them into GradientColors.
     97      *
     98      * @param inWallpaperColors Input.
     99      * @param outColorsNormal Colors for normal theme.
    100      * @param outColorsDark Colors for dar theme.
    101      * @param outColorsExtraDark Colors for extra dark theme.
    102      * @return True if succeeded or false if failed.
    103      */
    104     private boolean runTonalExtraction(@Nullable WallpaperColors inWallpaperColors,
    105             @NonNull GradientColors outColorsNormal, @NonNull GradientColors outColorsDark,
    106             @NonNull GradientColors outColorsExtraDark) {
    107 
    108         if (inWallpaperColors == null) {
    109             return false;
    110         }
    111 
    112         final List<Color> mainColors = inWallpaperColors.getMainColors();
    113         final int mainColorsSize = mainColors.size();
    114         final int hints = inWallpaperColors.getColorHints();
    115         final boolean supportsDarkText = (hints & WallpaperColors.HINT_SUPPORTS_DARK_TEXT) != 0;
    116         final boolean generatedFromBitmap = (hints & WallpaperColors.HINT_FROM_BITMAP) != 0;
    117 
    118         if (mainColorsSize == 0) {
    119             return false;
    120         }
    121 
    122         // Decide what's the best color to use.
    123         // We have 2 options:
    124         //  Just pick the primary color
    125         //  Filter out blacklisted colors. This is useful when palette is generated
    126         //   automatically from a bitmap.
    127         Color bestColor = null;
    128         final float[] hsl = new float[3];
    129         for (int i = 0; i < mainColorsSize; i++) {
    130             final Color color = mainColors.get(i);
    131             final int colorValue = color.toArgb();
    132             ColorUtils.RGBToHSL(Color.red(colorValue), Color.green(colorValue),
    133                     Color.blue(colorValue), hsl);
    134 
    135             // Stop when we find a color that meets our criteria
    136             if (!generatedFromBitmap || !isBlacklisted(hsl)) {
    137                 bestColor = color;
    138                 break;
    139             }
    140         }
    141 
    142         // Fail if not found
    143         if (bestColor == null) {
    144             return false;
    145         }
    146 
    147         // Tonal is not really a sort, it takes a color from the extracted
    148         // palette and finds a best fit amongst a collection of pre-defined
    149         // palettes. The best fit is tweaked to be closer to the source color
    150         // and replaces the original palette.
    151         int colorValue = bestColor.toArgb();
    152         ColorUtils.RGBToHSL(Color.red(colorValue), Color.green(colorValue), Color.blue(colorValue),
    153                 hsl);
    154 
    155         // The Android HSL definition requires the hue to go from 0 to 360 but
    156         // the Material Tonal Palette defines hues from 0 to 1.
    157         hsl[0] /= 360f;
    158 
    159         // Find the palette that contains the closest color
    160         TonalPalette palette = findTonalPalette(hsl[0], hsl[1]);
    161         if (palette == null) {
    162             Log.w(TAG, "Could not find a tonal palette!");
    163             return false;
    164         }
    165 
    166         // Figure out what's the main color index in the optimal palette
    167         int fitIndex = bestFit(palette, hsl[0], hsl[1], hsl[2]);
    168         if (fitIndex == -1) {
    169             Log.w(TAG, "Could not find best fit!");
    170             return false;
    171         }
    172 
    173         // Generate the 10 colors palette by offsetting each one of them
    174         float[] h = fit(palette.h, hsl[0], fitIndex,
    175                 Float.NEGATIVE_INFINITY, Float.POSITIVE_INFINITY);
    176         float[] s = fit(palette.s, hsl[1], fitIndex, 0.0f, 1.0f);
    177         float[] l = fit(palette.l, hsl[2], fitIndex, 0.0f, 1.0f);
    178 
    179         if (DEBUG) {
    180             StringBuilder builder = new StringBuilder("Tonal Palette - index: " + fitIndex +
    181                     ". Main color: " + Integer.toHexString(getColorInt(fitIndex, h, s, l)) +
    182                     "\nColors: ");
    183 
    184             for (int i=0; i < h.length; i++) {
    185                 builder.append(Integer.toHexString(getColorInt(i, h, s, l)));
    186                 if (i < h.length - 1) {
    187                     builder.append(", ");
    188                 }
    189             }
    190             Log.d(TAG, builder.toString());
    191         }
    192 
    193         int primaryIndex = fitIndex;
    194         int mainColor = getColorInt(primaryIndex, h, s, l);
    195 
    196         // We might want use the fallback in case the extracted color is brighter than our
    197         // light fallback or darker than our dark fallback.
    198         ColorUtils.colorToHSL(mainColor, mTmpHSL);
    199         final float mainLuminosity = mTmpHSL[2];
    200         ColorUtils.colorToHSL(MAIN_COLOR_LIGHT, mTmpHSL);
    201         final float lightLuminosity = mTmpHSL[2];
    202         if (mainLuminosity > lightLuminosity) {
    203             return false;
    204         }
    205         ColorUtils.colorToHSL(MAIN_COLOR_DARK, mTmpHSL);
    206         final float darkLuminosity = mTmpHSL[2];
    207         if (mainLuminosity < darkLuminosity) {
    208             return false;
    209         }
    210 
    211         // Normal colors:
    212         // best fit + a 2 colors offset
    213         outColorsNormal.setMainColor(mainColor);
    214         int secondaryIndex = primaryIndex + (primaryIndex >= 2 ? -2 : 2);
    215         outColorsNormal.setSecondaryColor(getColorInt(secondaryIndex, h, s, l));
    216 
    217         // Dark colors:
    218         // Stops at 4th color, only lighter if dark text is supported
    219         if (supportsDarkText) {
    220             primaryIndex = h.length - 1;
    221         } else if (fitIndex < 2) {
    222             primaryIndex = 0;
    223         } else {
    224             primaryIndex = Math.min(fitIndex, 3);
    225         }
    226         secondaryIndex = primaryIndex + (primaryIndex >= 2 ? -2 : 2);
    227         outColorsDark.setMainColor(getColorInt(primaryIndex, h, s, l));
    228         outColorsDark.setSecondaryColor(getColorInt(secondaryIndex, h, s, l));
    229 
    230         // Extra Dark:
    231         // Stay close to dark colors until dark text is supported
    232         if (supportsDarkText) {
    233             primaryIndex = h.length - 1;
    234         } else if (fitIndex < 2) {
    235             primaryIndex = 0;
    236         } else {
    237             primaryIndex = 2;
    238         }
    239         secondaryIndex = primaryIndex + (primaryIndex >= 2 ? -2 : 2);
    240         outColorsExtraDark.setMainColor(getColorInt(primaryIndex, h, s, l));
    241         outColorsExtraDark.setSecondaryColor(getColorInt(secondaryIndex, h, s, l));
    242 
    243         outColorsNormal.setSupportsDarkText(supportsDarkText);
    244         outColorsDark.setSupportsDarkText(supportsDarkText);
    245         outColorsExtraDark.setSupportsDarkText(supportsDarkText);
    246 
    247         if (DEBUG) {
    248             Log.d(TAG, "Gradients: \n\tNormal " + outColorsNormal + "\n\tDark " + outColorsDark
    249                     + "\n\tExtra dark: " + outColorsExtraDark);
    250         }
    251 
    252         return true;
    253     }
    254 
    255     private void applyFallback(@Nullable WallpaperColors inWallpaperColors,
    256             GradientColors outColorsNormal, GradientColors outColorsDark,
    257             GradientColors outColorsExtraDark) {
    258         applyFallback(inWallpaperColors, outColorsNormal);
    259         applyFallback(inWallpaperColors, outColorsDark);
    260         applyFallback(inWallpaperColors, outColorsExtraDark);
    261     }
    262 
    263     /**
    264      * Sets the gradient to the light or dark fallbacks based on the current wallpaper colors.
    265      *
    266      * @param inWallpaperColors Colors to read.
    267      * @param outGradientColors Destination.
    268      */
    269     public static void applyFallback(@Nullable WallpaperColors inWallpaperColors,
    270             @NonNull GradientColors outGradientColors) {
    271         boolean light = inWallpaperColors != null
    272                 && (inWallpaperColors.getColorHints() & WallpaperColors.HINT_SUPPORTS_DARK_TEXT)
    273                 != 0;
    274         int innerColor = light ? MAIN_COLOR_LIGHT : MAIN_COLOR_DARK;
    275         int outerColor = light ? SECONDARY_COLOR_LIGHT : SECONDARY_COLOR_DARK;
    276 
    277         outGradientColors.setMainColor(innerColor);
    278         outGradientColors.setSecondaryColor(outerColor);
    279         outGradientColors.setSupportsDarkText(light);
    280     }
    281 
    282     private int getColorInt(int fitIndex, float[] h, float[] s, float[] l) {
    283         mTmpHSL[0] = fract(h[fitIndex]) * 360.0f;
    284         mTmpHSL[1] = s[fitIndex];
    285         mTmpHSL[2] = l[fitIndex];
    286         return ColorUtils.HSLToColor(mTmpHSL);
    287     }
    288 
    289     /**
    290      * Checks if a given color exists in the blacklist
    291      * @param hsl float array with 3 components (H 0..360, S 0..1 and L 0..1)
    292      * @return true if color should be avoided
    293      */
    294     private boolean isBlacklisted(float[] hsl) {
    295         for (int i = mBlacklistedColors.size() - 1; i >= 0; i--) {
    296             ColorRange badRange = mBlacklistedColors.get(i);
    297             if (badRange.containsColor(hsl[0], hsl[1], hsl[2])) {
    298                 return true;
    299             }
    300         }
    301         return false;
    302     }
    303 
    304     /**
    305      * Offsets all colors by a delta, clamping values that go beyond what's
    306      * supported on the color space.
    307      * @param data what you want to fit
    308      * @param v how big should be the offset
    309      * @param index which index to calculate the delta against
    310      * @param min minimum accepted value (clamp)
    311      * @param max maximum accepted value (clamp)
    312      * @return new shifted palette
    313      */
    314     private static float[] fit(float[] data, float v, int index, float min, float max) {
    315         float[] fitData = new float[data.length];
    316         float delta = v - data[index];
    317 
    318         for (int i = 0; i < data.length; i++) {
    319             fitData[i] = MathUtils.constrain(data[i] + delta, min, max);
    320         }
    321 
    322         return fitData;
    323     }
    324 
    325     /**
    326      * Finds the closest color in a palette, given another HSL color
    327      *
    328      * @param palette where to search
    329      * @param h hue
    330      * @param s saturation
    331      * @param l lightness
    332      * @return closest index or -1 if palette is empty.
    333      */
    334     private static int bestFit(@NonNull TonalPalette palette, float h, float s, float l) {
    335         int minErrorIndex = -1;
    336         float minError = Float.POSITIVE_INFINITY;
    337 
    338         for (int i = 0; i < palette.h.length; i++) {
    339             float error =
    340                     FIT_WEIGHT_H * Math.abs(h - palette.h[i])
    341                             + FIT_WEIGHT_S * Math.abs(s - palette.s[i])
    342                             + FIT_WEIGHT_L * Math.abs(l - palette.l[i]);
    343             if (error < minError) {
    344                 minError = error;
    345                 minErrorIndex = i;
    346             }
    347         }
    348 
    349         return minErrorIndex;
    350     }
    351 
    352     @VisibleForTesting
    353     public List<ColorRange> getBlacklistedColors() {
    354         return mBlacklistedColors;
    355     }
    356 
    357     @Nullable
    358     private TonalPalette findTonalPalette(float h, float s) {
    359         // Fallback to a grey palette if the color is too desaturated.
    360         // This avoids hue shifts.
    361         if (s < 0.05f) {
    362             return mGreyPalette;
    363         }
    364 
    365         TonalPalette best = null;
    366         float error = Float.POSITIVE_INFINITY;
    367 
    368         final int tonalPalettesCount = mTonalPalettes.size();
    369         for (int i = 0; i < tonalPalettesCount; i++) {
    370             final TonalPalette candidate = mTonalPalettes.get(i);
    371 
    372             if (h >= candidate.minHue && h <= candidate.maxHue) {
    373                 best = candidate;
    374                 break;
    375             }
    376 
    377             if (candidate.maxHue > 1.0f && h >= 0.0f && h <= fract(candidate.maxHue)) {
    378                 best = candidate;
    379                 break;
    380             }
    381 
    382             if (candidate.minHue < 0.0f && h >= fract(candidate.minHue) && h <= 1.0f) {
    383                 best = candidate;
    384                 break;
    385             }
    386 
    387             if (h <= candidate.minHue && candidate.minHue - h < error) {
    388                 best = candidate;
    389                 error = candidate.minHue - h;
    390             } else if (h >= candidate.maxHue && h - candidate.maxHue < error) {
    391                 best = candidate;
    392                 error = h - candidate.maxHue;
    393             } else if (candidate.maxHue > 1.0f && h >= fract(candidate.maxHue)
    394                     && h - fract(candidate.maxHue) < error) {
    395                 best = candidate;
    396                 error = h - fract(candidate.maxHue);
    397             } else if (candidate.minHue < 0.0f && h <= fract(candidate.minHue)
    398                     && fract(candidate.minHue) - h < error) {
    399                 best = candidate;
    400                 error = fract(candidate.minHue) - h;
    401             }
    402         }
    403 
    404         return best;
    405     }
    406 
    407     private static float fract(float v) {
    408         return v - (float) Math.floor(v);
    409     }
    410 
    411     static class TonalPalette {
    412         final float[] h;
    413         final float[] s;
    414         final float[] l;
    415         final float minHue;
    416         final float maxHue;
    417 
    418         TonalPalette(float[] h, float[] s, float[] l) {
    419             if (h.length != s.length || s.length != l.length) {
    420                 throw new IllegalArgumentException("All arrays should have the same size. h: "
    421                         + Arrays.toString(h) + " s: " + Arrays.toString(s) + " l: "
    422                         + Arrays.toString(l));
    423             }
    424             this.h = h;
    425             this.s = s;
    426             this.l = l;
    427 
    428             float minHue = Float.POSITIVE_INFINITY;
    429             float maxHue = Float.NEGATIVE_INFINITY;
    430 
    431             for (float v : h) {
    432                 minHue = Math.min(v, minHue);
    433                 maxHue = Math.max(v, maxHue);
    434             }
    435 
    436             this.minHue = minHue;
    437             this.maxHue = maxHue;
    438         }
    439     }
    440 
    441     /**
    442      * Representation of an HSL color range.
    443      * <ul>
    444      * <li>hsl[0] is Hue [0 .. 360)</li>
    445      * <li>hsl[1] is Saturation [0...1]</li>
    446      * <li>hsl[2] is Lightness [0...1]</li>
    447      * </ul>
    448      */
    449     @VisibleForTesting
    450     public static class ColorRange {
    451         private Range<Float> mHue;
    452         private Range<Float> mSaturation;
    453         private Range<Float> mLightness;
    454 
    455         public ColorRange(Range<Float> hue, Range<Float> saturation, Range<Float> lightness) {
    456             mHue = hue;
    457             mSaturation = saturation;
    458             mLightness = lightness;
    459         }
    460 
    461         public boolean containsColor(float h, float s, float l) {
    462             if (!mHue.contains(h)) {
    463                 return false;
    464             } else if (!mSaturation.contains(s)) {
    465                 return false;
    466             } else if (!mLightness.contains(l)) {
    467                 return false;
    468             }
    469             return true;
    470         }
    471 
    472         public float[] getCenter() {
    473             return new float[] {
    474                     mHue.getLower() + (mHue.getUpper() - mHue.getLower()) / 2f,
    475                     mSaturation.getLower() + (mSaturation.getUpper() - mSaturation.getLower()) / 2f,
    476                     mLightness.getLower() + (mLightness.getUpper() - mLightness.getLower()) / 2f
    477             };
    478         }
    479 
    480         @Override
    481         public String toString() {
    482             return String.format("H: %s, S: %s, L %s", mHue, mSaturation, mLightness);
    483         }
    484     }
    485 
    486     @VisibleForTesting
    487     public static class ConfigParser {
    488         private final ArrayList<TonalPalette> mTonalPalettes;
    489         private final ArrayList<ColorRange> mBlacklistedColors;
    490 
    491         public ConfigParser(Context context) {
    492             mTonalPalettes = new ArrayList<>();
    493             mBlacklistedColors = new ArrayList<>();
    494 
    495             // Load all palettes and the blacklist from an XML.
    496             try {
    497                 XmlPullParser parser = context.getResources().getXml(R.xml.color_extraction);
    498                 int eventType = parser.getEventType();
    499                 while (eventType != XmlPullParser.END_DOCUMENT) {
    500                     if (eventType == XmlPullParser.START_DOCUMENT ||
    501                             eventType == XmlPullParser.END_TAG) {
    502                         // just skip
    503                     } else if (eventType == XmlPullParser.START_TAG) {
    504                         String tagName = parser.getName();
    505                         if (tagName.equals("palettes")) {
    506                             parsePalettes(parser);
    507                         } else if (tagName.equals("blacklist")) {
    508                             parseBlacklist(parser);
    509                         }
    510                     } else {
    511                         throw new XmlPullParserException("Invalid XML event " + eventType + " - "
    512                                 + parser.getName(), parser, null);
    513                     }
    514                     eventType = parser.next();
    515                 }
    516             } catch (XmlPullParserException | IOException e) {
    517                 throw new RuntimeException(e);
    518             }
    519         }
    520 
    521         public ArrayList<TonalPalette> getTonalPalettes() {
    522             return mTonalPalettes;
    523         }
    524 
    525         public ArrayList<ColorRange> getBlacklistedColors() {
    526             return mBlacklistedColors;
    527         }
    528 
    529         private void parseBlacklist(XmlPullParser parser)
    530                 throws XmlPullParserException, IOException {
    531             parser.require(XmlPullParser.START_TAG, null, "blacklist");
    532             while (parser.next() != XmlPullParser.END_TAG) {
    533                 if (parser.getEventType() != XmlPullParser.START_TAG) {
    534                     continue;
    535                 }
    536                 String name = parser.getName();
    537                 // Starts by looking for the entry tag
    538                 if (name.equals("range")) {
    539                     mBlacklistedColors.add(readRange(parser));
    540                     parser.next();
    541                 } else {
    542                     throw new XmlPullParserException("Invalid tag: " + name, parser, null);
    543                 }
    544             }
    545         }
    546 
    547         private ColorRange readRange(XmlPullParser parser)
    548                 throws XmlPullParserException, IOException {
    549             parser.require(XmlPullParser.START_TAG, null, "range");
    550             float[] h = readFloatArray(parser.getAttributeValue(null, "h"));
    551             float[] s = readFloatArray(parser.getAttributeValue(null, "s"));
    552             float[] l = readFloatArray(parser.getAttributeValue(null, "l"));
    553 
    554             if (h == null || s == null || l == null) {
    555                 throw new XmlPullParserException("Incomplete range tag.", parser, null);
    556             }
    557 
    558             return new ColorRange(new Range<>(h[0], h[1]), new Range<>(s[0], s[1]),
    559                     new Range<>(l[0], l[1]));
    560         }
    561 
    562         private void parsePalettes(XmlPullParser parser)
    563                 throws XmlPullParserException, IOException {
    564             parser.require(XmlPullParser.START_TAG, null, "palettes");
    565             while (parser.next() != XmlPullParser.END_TAG) {
    566                 if (parser.getEventType() != XmlPullParser.START_TAG) {
    567                     continue;
    568                 }
    569                 String name = parser.getName();
    570                 // Starts by looking for the entry tag
    571                 if (name.equals("palette")) {
    572                     mTonalPalettes.add(readPalette(parser));
    573                     parser.next();
    574                 } else {
    575                     throw new XmlPullParserException("Invalid tag: " + name);
    576                 }
    577             }
    578         }
    579 
    580         private TonalPalette readPalette(XmlPullParser parser)
    581                 throws XmlPullParserException, IOException {
    582             parser.require(XmlPullParser.START_TAG, null, "palette");
    583 
    584             float[] h = readFloatArray(parser.getAttributeValue(null, "h"));
    585             float[] s = readFloatArray(parser.getAttributeValue(null, "s"));
    586             float[] l = readFloatArray(parser.getAttributeValue(null, "l"));
    587 
    588             if (h == null || s == null || l == null) {
    589                 throw new XmlPullParserException("Incomplete range tag.", parser, null);
    590             }
    591 
    592             return new TonalPalette(h, s, l);
    593         }
    594 
    595         private float[] readFloatArray(String attributeValue)
    596                 throws IOException, XmlPullParserException {
    597             String[] tokens = attributeValue.replaceAll(" ", "").replaceAll("\n", "").split(",");
    598             float[] numbers = new float[tokens.length];
    599             for (int i = 0; i < tokens.length; i++) {
    600                 numbers[i] = Float.parseFloat(tokens[i]);
    601             }
    602             return numbers;
    603         }
    604     }
    605 }