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 }