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 }