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