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