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 THRESHOLD_COLOR_LIGHT = 0xffe0e0e0;
     55     public static final int MAIN_COLOR_LIGHT = 0xffe0e0e0;
     56     public static final int THRESHOLD_COLOR_DARK = 0xff212121;
     57     public static final int MAIN_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(THRESHOLD_COLOR_LIGHT, mTmpHSL);
    201         final float lightLuminosity = mTmpHSL[2];
    202         if (mainLuminosity > lightLuminosity) {
    203             return false;
    204         }
    205         ColorUtils.colorToHSL(THRESHOLD_COLOR_DARK, mTmpHSL);
    206         final float darkLuminosity = mTmpHSL[2];
    207         if (mainLuminosity < darkLuminosity) {
    208             return false;
    209         }
    210 
    211         // Normal colors:
    212         outColorsNormal.setMainColor(mainColor);
    213         outColorsNormal.setSecondaryColor(mainColor);
    214 
    215         // Dark colors:
    216         // Stops at 4th color, only lighter if dark text is supported
    217         if (supportsDarkText) {
    218             primaryIndex = h.length - 1;
    219         } else if (fitIndex < 2) {
    220             primaryIndex = 0;
    221         } else {
    222             primaryIndex = Math.min(fitIndex, 3);
    223         }
    224         mainColor = getColorInt(primaryIndex, h, s, l);
    225         outColorsDark.setMainColor(mainColor);
    226         outColorsDark.setSecondaryColor(mainColor);
    227 
    228         // Extra Dark:
    229         // Stay close to dark colors until dark text is supported
    230         if (supportsDarkText) {
    231             primaryIndex = h.length - 1;
    232         } else if (fitIndex < 2) {
    233             primaryIndex = 0;
    234         } else {
    235             primaryIndex = 2;
    236         }
    237         mainColor = getColorInt(primaryIndex, h, s, l);
    238         outColorsExtraDark.setMainColor(mainColor);
    239         outColorsExtraDark.setSecondaryColor(mainColor);
    240 
    241         outColorsNormal.setSupportsDarkText(supportsDarkText);
    242         outColorsDark.setSupportsDarkText(supportsDarkText);
    243         outColorsExtraDark.setSupportsDarkText(supportsDarkText);
    244 
    245         if (DEBUG) {
    246             Log.d(TAG, "Gradients: \n\tNormal " + outColorsNormal + "\n\tDark " + outColorsDark
    247                     + "\n\tExtra dark: " + outColorsExtraDark);
    248         }
    249 
    250         return true;
    251     }
    252 
    253     private void applyFallback(@Nullable WallpaperColors inWallpaperColors,
    254             GradientColors outColorsNormal, GradientColors outColorsDark,
    255             GradientColors outColorsExtraDark) {
    256         applyFallback(inWallpaperColors, outColorsNormal);
    257         applyFallback(inWallpaperColors, outColorsDark);
    258         applyFallback(inWallpaperColors, outColorsExtraDark);
    259     }
    260 
    261     /**
    262      * Sets the gradient to the light or dark fallbacks based on the current wallpaper colors.
    263      *
    264      * @param inWallpaperColors Colors to read.
    265      * @param outGradientColors Destination.
    266      */
    267     public static void applyFallback(@Nullable WallpaperColors inWallpaperColors,
    268             @NonNull GradientColors outGradientColors) {
    269         boolean light = inWallpaperColors != null
    270                 && (inWallpaperColors.getColorHints() & WallpaperColors.HINT_SUPPORTS_DARK_TEXT)
    271                 != 0;
    272         final int color = light ? MAIN_COLOR_LIGHT : MAIN_COLOR_DARK;
    273 
    274         outGradientColors.setMainColor(color);
    275         outGradientColors.setSecondaryColor(color);
    276         outGradientColors.setSupportsDarkText(light);
    277     }
    278 
    279     private int getColorInt(int fitIndex, float[] h, float[] s, float[] l) {
    280         mTmpHSL[0] = fract(h[fitIndex]) * 360.0f;
    281         mTmpHSL[1] = s[fitIndex];
    282         mTmpHSL[2] = l[fitIndex];
    283         return ColorUtils.HSLToColor(mTmpHSL);
    284     }
    285 
    286     /**
    287      * Checks if a given color exists in the blacklist
    288      * @param hsl float array with 3 components (H 0..360, S 0..1 and L 0..1)
    289      * @return true if color should be avoided
    290      */
    291     private boolean isBlacklisted(float[] hsl) {
    292         for (int i = mBlacklistedColors.size() - 1; i >= 0; i--) {
    293             ColorRange badRange = mBlacklistedColors.get(i);
    294             if (badRange.containsColor(hsl[0], hsl[1], hsl[2])) {
    295                 return true;
    296             }
    297         }
    298         return false;
    299     }
    300 
    301     /**
    302      * Offsets all colors by a delta, clamping values that go beyond what's
    303      * supported on the color space.
    304      * @param data what you want to fit
    305      * @param v how big should be the offset
    306      * @param index which index to calculate the delta against
    307      * @param min minimum accepted value (clamp)
    308      * @param max maximum accepted value (clamp)
    309      * @return new shifted palette
    310      */
    311     private static float[] fit(float[] data, float v, int index, float min, float max) {
    312         float[] fitData = new float[data.length];
    313         float delta = v - data[index];
    314 
    315         for (int i = 0; i < data.length; i++) {
    316             fitData[i] = MathUtils.constrain(data[i] + delta, min, max);
    317         }
    318 
    319         return fitData;
    320     }
    321 
    322     /**
    323      * Finds the closest color in a palette, given another HSL color
    324      *
    325      * @param palette where to search
    326      * @param h hue
    327      * @param s saturation
    328      * @param l lightness
    329      * @return closest index or -1 if palette is empty.
    330      */
    331     private static int bestFit(@NonNull TonalPalette palette, float h, float s, float l) {
    332         int minErrorIndex = -1;
    333         float minError = Float.POSITIVE_INFINITY;
    334 
    335         for (int i = 0; i < palette.h.length; i++) {
    336             float error =
    337                     FIT_WEIGHT_H * Math.abs(h - palette.h[i])
    338                             + FIT_WEIGHT_S * Math.abs(s - palette.s[i])
    339                             + FIT_WEIGHT_L * Math.abs(l - palette.l[i]);
    340             if (error < minError) {
    341                 minError = error;
    342                 minErrorIndex = i;
    343             }
    344         }
    345 
    346         return minErrorIndex;
    347     }
    348 
    349     @VisibleForTesting
    350     public List<ColorRange> getBlacklistedColors() {
    351         return mBlacklistedColors;
    352     }
    353 
    354     @Nullable
    355     private TonalPalette findTonalPalette(float h, float s) {
    356         // Fallback to a grey palette if the color is too desaturated.
    357         // This avoids hue shifts.
    358         if (s < 0.05f) {
    359             return mGreyPalette;
    360         }
    361 
    362         TonalPalette best = null;
    363         float error = Float.POSITIVE_INFINITY;
    364 
    365         final int tonalPalettesCount = mTonalPalettes.size();
    366         for (int i = 0; i < tonalPalettesCount; i++) {
    367             final TonalPalette candidate = mTonalPalettes.get(i);
    368 
    369             if (h >= candidate.minHue && h <= candidate.maxHue) {
    370                 best = candidate;
    371                 break;
    372             }
    373 
    374             if (candidate.maxHue > 1.0f && h >= 0.0f && h <= fract(candidate.maxHue)) {
    375                 best = candidate;
    376                 break;
    377             }
    378 
    379             if (candidate.minHue < 0.0f && h >= fract(candidate.minHue) && h <= 1.0f) {
    380                 best = candidate;
    381                 break;
    382             }
    383 
    384             if (h <= candidate.minHue && candidate.minHue - h < error) {
    385                 best = candidate;
    386                 error = candidate.minHue - h;
    387             } else if (h >= candidate.maxHue && h - candidate.maxHue < error) {
    388                 best = candidate;
    389                 error = h - candidate.maxHue;
    390             } else if (candidate.maxHue > 1.0f && h >= fract(candidate.maxHue)
    391                     && h - fract(candidate.maxHue) < error) {
    392                 best = candidate;
    393                 error = h - fract(candidate.maxHue);
    394             } else if (candidate.minHue < 0.0f && h <= fract(candidate.minHue)
    395                     && fract(candidate.minHue) - h < error) {
    396                 best = candidate;
    397                 error = fract(candidate.minHue) - h;
    398             }
    399         }
    400 
    401         return best;
    402     }
    403 
    404     private static float fract(float v) {
    405         return v - (float) Math.floor(v);
    406     }
    407 
    408     @VisibleForTesting
    409     public static class TonalPalette {
    410         public final float[] h;
    411         public final float[] s;
    412         public final float[] l;
    413         public final float minHue;
    414         public final float maxHue;
    415 
    416         TonalPalette(float[] h, float[] s, float[] l) {
    417             if (h.length != s.length || s.length != l.length) {
    418                 throw new IllegalArgumentException("All arrays should have the same size. h: "
    419                         + Arrays.toString(h) + " s: " + Arrays.toString(s) + " l: "
    420                         + Arrays.toString(l));
    421             }
    422             this.h = h;
    423             this.s = s;
    424             this.l = l;
    425 
    426             float minHue = Float.POSITIVE_INFINITY;
    427             float maxHue = Float.NEGATIVE_INFINITY;
    428 
    429             for (float v : h) {
    430                 minHue = Math.min(v, minHue);
    431                 maxHue = Math.max(v, maxHue);
    432             }
    433 
    434             this.minHue = minHue;
    435             this.maxHue = maxHue;
    436         }
    437     }
    438 
    439     /**
    440      * Representation of an HSL color range.
    441      * <ul>
    442      * <li>hsl[0] is Hue [0 .. 360)</li>
    443      * <li>hsl[1] is Saturation [0...1]</li>
    444      * <li>hsl[2] is Lightness [0...1]</li>
    445      * </ul>
    446      */
    447     @VisibleForTesting
    448     public static class ColorRange {
    449         private Range<Float> mHue;
    450         private Range<Float> mSaturation;
    451         private Range<Float> mLightness;
    452 
    453         public ColorRange(Range<Float> hue, Range<Float> saturation, Range<Float> lightness) {
    454             mHue = hue;
    455             mSaturation = saturation;
    456             mLightness = lightness;
    457         }
    458 
    459         public boolean containsColor(float h, float s, float l) {
    460             if (!mHue.contains(h)) {
    461                 return false;
    462             } else if (!mSaturation.contains(s)) {
    463                 return false;
    464             } else if (!mLightness.contains(l)) {
    465                 return false;
    466             }
    467             return true;
    468         }
    469 
    470         public float[] getCenter() {
    471             return new float[] {
    472                     mHue.getLower() + (mHue.getUpper() - mHue.getLower()) / 2f,
    473                     mSaturation.getLower() + (mSaturation.getUpper() - mSaturation.getLower()) / 2f,
    474                     mLightness.getLower() + (mLightness.getUpper() - mLightness.getLower()) / 2f
    475             };
    476         }
    477 
    478         @Override
    479         public String toString() {
    480             return String.format("H: %s, S: %s, L %s", mHue, mSaturation, mLightness);
    481         }
    482     }
    483 
    484     @VisibleForTesting
    485     public static class ConfigParser {
    486         private final ArrayList<TonalPalette> mTonalPalettes;
    487         private final ArrayList<ColorRange> mBlacklistedColors;
    488 
    489         public ConfigParser(Context context) {
    490             mTonalPalettes = new ArrayList<>();
    491             mBlacklistedColors = new ArrayList<>();
    492 
    493             // Load all palettes and the blacklist from an XML.
    494             try {
    495                 XmlPullParser parser = context.getResources().getXml(R.xml.color_extraction);
    496                 int eventType = parser.getEventType();
    497                 while (eventType != XmlPullParser.END_DOCUMENT) {
    498                     if (eventType == XmlPullParser.START_DOCUMENT ||
    499                             eventType == XmlPullParser.END_TAG) {
    500                         // just skip
    501                     } else if (eventType == XmlPullParser.START_TAG) {
    502                         String tagName = parser.getName();
    503                         if (tagName.equals("palettes")) {
    504                             parsePalettes(parser);
    505                         } else if (tagName.equals("blacklist")) {
    506                             parseBlacklist(parser);
    507                         }
    508                     } else {
    509                         throw new XmlPullParserException("Invalid XML event " + eventType + " - "
    510                                 + parser.getName(), parser, null);
    511                     }
    512                     eventType = parser.next();
    513                 }
    514             } catch (XmlPullParserException | IOException e) {
    515                 throw new RuntimeException(e);
    516             }
    517         }
    518 
    519         public ArrayList<TonalPalette> getTonalPalettes() {
    520             return mTonalPalettes;
    521         }
    522 
    523         public ArrayList<ColorRange> getBlacklistedColors() {
    524             return mBlacklistedColors;
    525         }
    526 
    527         private void parseBlacklist(XmlPullParser parser)
    528                 throws XmlPullParserException, IOException {
    529             parser.require(XmlPullParser.START_TAG, null, "blacklist");
    530             while (parser.next() != XmlPullParser.END_TAG) {
    531                 if (parser.getEventType() != XmlPullParser.START_TAG) {
    532                     continue;
    533                 }
    534                 String name = parser.getName();
    535                 // Starts by looking for the entry tag
    536                 if (name.equals("range")) {
    537                     mBlacklistedColors.add(readRange(parser));
    538                     parser.next();
    539                 } else {
    540                     throw new XmlPullParserException("Invalid tag: " + name, parser, null);
    541                 }
    542             }
    543         }
    544 
    545         private ColorRange readRange(XmlPullParser parser)
    546                 throws XmlPullParserException, IOException {
    547             parser.require(XmlPullParser.START_TAG, null, "range");
    548             float[] h = readFloatArray(parser.getAttributeValue(null, "h"));
    549             float[] s = readFloatArray(parser.getAttributeValue(null, "s"));
    550             float[] l = readFloatArray(parser.getAttributeValue(null, "l"));
    551 
    552             if (h == null || s == null || l == null) {
    553                 throw new XmlPullParserException("Incomplete range tag.", parser, null);
    554             }
    555 
    556             return new ColorRange(new Range<>(h[0], h[1]), new Range<>(s[0], s[1]),
    557                     new Range<>(l[0], l[1]));
    558         }
    559 
    560         private void parsePalettes(XmlPullParser parser)
    561                 throws XmlPullParserException, IOException {
    562             parser.require(XmlPullParser.START_TAG, null, "palettes");
    563             while (parser.next() != XmlPullParser.END_TAG) {
    564                 if (parser.getEventType() != XmlPullParser.START_TAG) {
    565                     continue;
    566                 }
    567                 String name = parser.getName();
    568                 // Starts by looking for the entry tag
    569                 if (name.equals("palette")) {
    570                     mTonalPalettes.add(readPalette(parser));
    571                     parser.next();
    572                 } else {
    573                     throw new XmlPullParserException("Invalid tag: " + name);
    574                 }
    575             }
    576         }
    577 
    578         private TonalPalette readPalette(XmlPullParser parser)
    579                 throws XmlPullParserException, IOException {
    580             parser.require(XmlPullParser.START_TAG, null, "palette");
    581 
    582             float[] h = readFloatArray(parser.getAttributeValue(null, "h"));
    583             float[] s = readFloatArray(parser.getAttributeValue(null, "s"));
    584             float[] l = readFloatArray(parser.getAttributeValue(null, "l"));
    585 
    586             if (h == null || s == null || l == null) {
    587                 throw new XmlPullParserException("Incomplete range tag.", parser, null);
    588             }
    589 
    590             return new TonalPalette(h, s, l);
    591         }
    592 
    593         private float[] readFloatArray(String attributeValue)
    594                 throws IOException, XmlPullParserException {
    595             String[] tokens = attributeValue.replaceAll(" ", "").replaceAll("\n", "").split(",");
    596             float[] numbers = new float[tokens.length];
    597             for (int i = 0; i < tokens.length; i++) {
    598                 numbers[i] = Float.parseFloat(tokens[i]);
    599             }
    600             return numbers;
    601         }
    602     }
    603 }