1 /* <lambda>null2 * Copyright (C) 2021 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.systemui.monet 18 19 import android.annotation.ColorInt 20 import android.app.WallpaperColors 21 import android.graphics.Color 22 import com.android.internal.graphics.ColorUtils 23 import com.android.internal.graphics.cam.Cam 24 import com.android.internal.graphics.cam.CamUtils 25 import kotlin.math.absoluteValue 26 import kotlin.math.roundToInt 27 28 const val TAG = "ColorScheme" 29 30 const val ACCENT1_CHROMA = 48.0f 31 const val GOOGLE_BLUE = 0xFF1b6ef3.toInt() 32 const val MIN_CHROMA = 5 33 34 internal interface Hue { 35 fun get(sourceColor: Cam): Double 36 37 /** 38 * Given a hue, and a mapping of hues to hue rotations, find which hues in the mapping the 39 * hue fall betweens, and use the hue rotation of the lower hue. 40 * 41 * @param sourceHue hue of source color 42 * @param hueAndRotations list of pairs, where the first item in a pair is a hue, and the 43 * second item in the pair is a hue rotation that should be applied 44 */ 45 fun getHueRotation(sourceHue: Float, hueAndRotations: List<Pair<Int, Int>>): Double { 46 val sanitizedSourceHue = (if (sourceHue < 0 || sourceHue >= 360) 0 else sourceHue).toFloat() 47 for (i in 0..hueAndRotations.size - 2) { 48 val thisHue = hueAndRotations[i].first.toFloat() 49 val nextHue = hueAndRotations[i + 1].first.toFloat() 50 if (thisHue <= sanitizedSourceHue && sanitizedSourceHue < nextHue) { 51 return ColorScheme.wrapDegreesDouble(sanitizedSourceHue.toDouble() + 52 hueAndRotations[i].second) 53 } 54 } 55 56 // If this statement executes, something is wrong, there should have been a rotation 57 // found using the arrays. 58 return sourceHue.toDouble() 59 } 60 } 61 62 internal class HueSource : Hue { getnull63 override fun get(sourceColor: Cam): Double { 64 return sourceColor.hue.toDouble() 65 } 66 } 67 68 internal class HueAdd(val amountDegrees: Double) : Hue { getnull69 override fun get(sourceColor: Cam): Double { 70 return ColorScheme.wrapDegreesDouble(sourceColor.hue.toDouble() + amountDegrees) 71 } 72 } 73 74 internal class HueSubtract(val amountDegrees: Double) : Hue { getnull75 override fun get(sourceColor: Cam): Double { 76 return ColorScheme.wrapDegreesDouble(sourceColor.hue.toDouble() - amountDegrees) 77 } 78 } 79 80 internal class HueVibrantSecondary() : Hue { 81 val hueToRotations = listOf(Pair(0, 18), Pair(41, 15), Pair(61, 10), Pair(101, 12), 82 Pair(131, 15), Pair(181, 18), Pair(251, 15), Pair(301, 12), Pair(360, 12)) 83 getnull84 override fun get(sourceColor: Cam): Double { 85 return getHueRotation(sourceColor.hue, hueToRotations) 86 } 87 } 88 89 internal class HueVibrantTertiary() : Hue { 90 val hueToRotations = listOf(Pair(0, 35), Pair(41, 30), Pair(61, 20), Pair(101, 25), 91 Pair(131, 30), Pair(181, 35), Pair(251, 30), Pair(301, 25), Pair(360, 25)) 92 getnull93 override fun get(sourceColor: Cam): Double { 94 return getHueRotation(sourceColor.hue, hueToRotations) 95 } 96 } 97 98 internal class HueExpressiveSecondary() : Hue { 99 val hueToRotations = listOf(Pair(0, 45), Pair(21, 95), Pair(51, 45), Pair(121, 20), 100 Pair(151, 45), Pair(191, 90), Pair(271, 45), Pair(321, 45), Pair(360, 45)) 101 getnull102 override fun get(sourceColor: Cam): Double { 103 return getHueRotation(sourceColor.hue, hueToRotations) 104 } 105 } 106 107 internal class HueExpressiveTertiary() : Hue { 108 val hueToRotations = listOf(Pair(0, 120), Pair(21, 120), Pair(51, 20), Pair(121, 45), 109 Pair(151, 20), Pair(191, 15), Pair(271, 20), Pair(321, 120), Pair(360, 120)) 110 getnull111 override fun get(sourceColor: Cam): Double { 112 return getHueRotation(sourceColor.hue, hueToRotations) 113 } 114 } 115 116 internal interface Chroma { getnull117 fun get(sourceColor: Cam): Double 118 } 119 120 internal class ChromaMaxOut : Chroma { 121 override fun get(sourceColor: Cam): Double { 122 // Intentionally high. Gamut mapping from impossible HCT to sRGB will ensure that 123 // the maximum chroma is reached, even if lower than this constant. 124 return 130.0 125 } 126 } 127 128 internal class ChromaMultiple(val multiple: Double) : Chroma { getnull129 override fun get(sourceColor: Cam): Double { 130 return sourceColor.chroma * multiple 131 } 132 } 133 134 internal class ChromaConstant(val chroma: Double) : Chroma { getnull135 override fun get(sourceColor: Cam): Double { 136 return chroma 137 } 138 } 139 140 internal class ChromaSource : Chroma { getnull141 override fun get(sourceColor: Cam): Double { 142 return sourceColor.chroma.toDouble() 143 } 144 } 145 146 internal class TonalSpec(val hue: Hue = HueSource(), val chroma: Chroma) { shadesnull147 fun shades(sourceColor: Cam): List<Int> { 148 val hue = hue.get(sourceColor) 149 val chroma = chroma.get(sourceColor) 150 return Shades.of(hue.toFloat(), chroma.toFloat()).toList() 151 } 152 } 153 154 internal class CoreSpec( 155 val a1: TonalSpec, 156 val a2: TonalSpec, 157 val a3: TonalSpec, 158 val n1: TonalSpec, 159 val n2: TonalSpec 160 ) 161 162 enum class Style(internal val coreSpec: CoreSpec) { 163 SPRITZ(CoreSpec( 164 a1 = TonalSpec(HueSource(), ChromaConstant(12.0)), 165 a2 = TonalSpec(HueSource(), ChromaConstant(8.0)), 166 a3 = TonalSpec(HueSource(), ChromaConstant(16.0)), 167 n1 = TonalSpec(HueSource(), ChromaConstant(2.0)), 168 n2 = TonalSpec(HueSource(), ChromaConstant(2.0)) 169 )), 170 TONAL_SPOT(CoreSpec( 171 a1 = TonalSpec(HueSource(), ChromaConstant(36.0)), 172 a2 = TonalSpec(HueSource(), ChromaConstant(16.0)), 173 a3 = TonalSpec(HueAdd(60.0), ChromaConstant(24.0)), 174 n1 = TonalSpec(HueSource(), ChromaConstant(4.0)), 175 n2 = TonalSpec(HueSource(), ChromaConstant(8.0)) 176 )), 177 VIBRANT(CoreSpec( 178 a1 = TonalSpec(HueSource(), ChromaMaxOut()), 179 a2 = TonalSpec(HueVibrantSecondary(), ChromaConstant(24.0)), 180 a3 = TonalSpec(HueVibrantTertiary(), ChromaConstant(32.0)), 181 n1 = TonalSpec(HueSource(), ChromaConstant(10.0)), 182 n2 = TonalSpec(HueSource(), ChromaConstant(12.0)) 183 )), 184 EXPRESSIVE(CoreSpec( 185 a1 = TonalSpec(HueAdd(240.0), ChromaConstant(40.0)), 186 a2 = TonalSpec(HueExpressiveSecondary(), ChromaConstant(24.0)), 187 a3 = TonalSpec(HueExpressiveTertiary(), ChromaConstant(32.0)), 188 n1 = TonalSpec(HueAdd(15.0), ChromaConstant(8.0)), 189 n2 = TonalSpec(HueAdd(15.0), ChromaConstant(12.0)) 190 )), 191 RAINBOW(CoreSpec( 192 a1 = TonalSpec(HueSource(), ChromaConstant(48.0)), 193 a2 = TonalSpec(HueSource(), ChromaConstant(16.0)), 194 a3 = TonalSpec(HueAdd(60.0), ChromaConstant(24.0)), 195 n1 = TonalSpec(HueSource(), ChromaConstant(0.0)), 196 n2 = TonalSpec(HueSource(), ChromaConstant(0.0)) 197 )), 198 FRUIT_SALAD(CoreSpec( 199 a1 = TonalSpec(HueSubtract(50.0), ChromaConstant(48.0)), 200 a2 = TonalSpec(HueSubtract(50.0), ChromaConstant(36.0)), 201 a3 = TonalSpec(HueSource(), ChromaConstant(36.0)), 202 n1 = TonalSpec(HueSource(), ChromaConstant(10.0)), 203 n2 = TonalSpec(HueSource(), ChromaConstant(16.0)) 204 )), 205 CONTENT(CoreSpec( 206 a1 = TonalSpec(HueSource(), ChromaSource()), 207 a2 = TonalSpec(HueSource(), ChromaMultiple(0.33)), 208 a3 = TonalSpec(HueSource(), ChromaMultiple(0.66)), 209 n1 = TonalSpec(HueSource(), ChromaMultiple(0.0833)), 210 n2 = TonalSpec(HueSource(), ChromaMultiple(0.1666)) 211 )), 212 MONOCHROMATIC(CoreSpec( 213 a1 = TonalSpec(HueSource(), ChromaConstant(.0)), 214 a2 = TonalSpec(HueSource(), ChromaConstant(.0)), 215 a3 = TonalSpec(HueSource(), ChromaConstant(.0)), 216 n1 = TonalSpec(HueSource(), ChromaConstant(.0)), 217 n2 = TonalSpec(HueSource(), ChromaConstant(.0)) 218 )), 219 } 220 221 class TonalPalette { 222 val shadeKeys = listOf(10, 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000) 223 val allShades: List<Int> 224 val allShadesMapped: Map<Int, Int> 225 val baseColor: Int 226 227 internal constructor(spec: TonalSpec, seedColor: Int) { 228 val seedCam = Cam.fromInt(seedColor) 229 allShades = spec.shades(seedCam) 230 allShadesMapped = shadeKeys.zip(allShades).toMap() 231 232 val h = spec.hue.get(seedCam).toFloat() 233 val c = spec.chroma.get(seedCam).toFloat() 234 baseColor = ColorUtils.CAMToColor(h, c, CamUtils.lstarFromInt(seedColor)) 235 } 236 237 val s10: Int get() = this.allShades[0] 238 val s50: Int get() = this.allShades[1] 239 val s100: Int get() = this.allShades[2] 240 val s200: Int get() = this.allShades[3] 241 val s300: Int get() = this.allShades[4] 242 val s400: Int get() = this.allShades[5] 243 val s500: Int get() = this.allShades[6] 244 val s600: Int get() = this.allShades[7] 245 val s700: Int get() = this.allShades[8] 246 val s800: Int get() = this.allShades[9] 247 val s900: Int get() = this.allShades[10] 248 val s1000: Int get() = this.allShades[11] 249 } 250 251 class ColorScheme( 252 @ColorInt val seed: Int, 253 val darkTheme: Boolean, 254 val style: Style = Style.TONAL_SPOT 255 ) { 256 257 val accent1: TonalPalette 258 val accent2: TonalPalette 259 val accent3: TonalPalette 260 val neutral1: TonalPalette 261 val neutral2: TonalPalette 262 263 constructor(@ColorInt seed: Int, darkTheme: Boolean) : 264 this(seed, darkTheme, Style.TONAL_SPOT) 265 266 @JvmOverloads 267 constructor( 268 wallpaperColors: WallpaperColors, 269 darkTheme: Boolean, 270 style: Style = Style.TONAL_SPOT 271 ) : 272 this(getSeedColor(wallpaperColors, style != Style.CONTENT), darkTheme, style) 273 274 val allHues: List<TonalPalette> 275 get() { 276 return listOf(accent1, accent2, accent3, neutral1, neutral2) 277 } 278 279 val allAccentColors: List<Int> 280 get() { 281 val allColors = mutableListOf<Int>() 282 allColors.addAll(accent1.allShades) 283 allColors.addAll(accent2.allShades) 284 allColors.addAll(accent3.allShades) 285 return allColors 286 } 287 288 val allNeutralColors: List<Int> 289 get() { 290 val allColors = mutableListOf<Int>() 291 allColors.addAll(neutral1.allShades) 292 allColors.addAll(neutral2.allShades) 293 return allColors 294 } 295 296 val backgroundColor 297 get() = ColorUtils.setAlphaComponent(if (darkTheme) neutral1.s700 else neutral1.s10, 0xFF) 298 299 val accentColor 300 get() = ColorUtils.setAlphaComponent(if (darkTheme) accent1.s100 else accent1.s500, 0xFF) 301 302 init { 303 val proposedSeedCam = Cam.fromInt(seed) 304 val seedArgb = if (seed == Color.TRANSPARENT) { 305 GOOGLE_BLUE 306 } else if (style != Style.CONTENT && proposedSeedCam.chroma < 5) { 307 GOOGLE_BLUE 308 } else { 309 seed 310 } 311 312 accent1 = TonalPalette(style.coreSpec.a1, seedArgb) 313 accent2 = TonalPalette(style.coreSpec.a2, seedArgb) 314 accent3 = TonalPalette(style.coreSpec.a3, seedArgb) 315 neutral1 = TonalPalette(style.coreSpec.n1, seedArgb) 316 neutral2 = TonalPalette(style.coreSpec.n2, seedArgb) 317 } 318 319 val shadeCount get() = this.accent1.allShades.size 320 toStringnull321 override fun toString(): String { 322 return "ColorScheme {\n" + 323 " seed color: ${stringForColor(seed)}\n" + 324 " style: $style\n" + 325 " palettes: \n" + 326 " ${humanReadable("PRIMARY", accent1.allShades)}\n" + 327 " ${humanReadable("SECONDARY", accent2.allShades)}\n" + 328 " ${humanReadable("TERTIARY", accent3.allShades)}\n" + 329 " ${humanReadable("NEUTRAL", neutral1.allShades)}\n" + 330 " ${humanReadable("NEUTRAL VARIANT", neutral2.allShades)}\n" + 331 "}" 332 } 333 334 companion object { 335 /** 336 * Identifies a color to create a color scheme from. 337 * 338 * @param wallpaperColors Colors extracted from an image via quantization. 339 * @param filter If false, allow colors that have low chroma, creating grayscale themes. 340 * @return ARGB int representing the color 341 */ 342 @JvmStatic 343 @JvmOverloads 344 @ColorInt getSeedColornull345 fun getSeedColor(wallpaperColors: WallpaperColors, filter: Boolean = true): Int { 346 return getSeedColors(wallpaperColors, filter).first() 347 } 348 349 /** 350 * Filters and ranks colors from WallpaperColors. 351 * 352 * @param wallpaperColors Colors extracted from an image via quantization. 353 * @param filter If false, allow colors that have low chroma, creating grayscale themes. 354 * @return List of ARGB ints, ordered from highest scoring to lowest. 355 */ 356 @JvmStatic 357 @JvmOverloads getSeedColorsnull358 fun getSeedColors(wallpaperColors: WallpaperColors, filter: Boolean = true): List<Int> { 359 val totalPopulation = wallpaperColors.allColors.values.reduce { a, b -> a + b } 360 .toDouble() 361 val totalPopulationMeaningless = (totalPopulation == 0.0) 362 if (totalPopulationMeaningless) { 363 // WallpaperColors with a population of 0 indicate the colors didn't come from 364 // quantization. Instead of scoring, trust the ordering of the provided primary 365 // secondary/tertiary colors. 366 // 367 // In this case, the colors are usually from a Live Wallpaper. 368 val distinctColors = wallpaperColors.mainColors.map { 369 it.toArgb() 370 }.distinct().filter { 371 if (!filter) { 372 true 373 } else { 374 Cam.fromInt(it).chroma >= MIN_CHROMA 375 } 376 }.toList() 377 if (distinctColors.isEmpty()) { 378 return listOf(GOOGLE_BLUE) 379 } 380 return distinctColors 381 } 382 383 val intToProportion = wallpaperColors.allColors.mapValues { 384 it.value.toDouble() / totalPopulation 385 } 386 val intToCam = wallpaperColors.allColors.mapValues { Cam.fromInt(it.key) } 387 388 // Get an array with 360 slots. A slot contains the percentage of colors with that hue. 389 val hueProportions = huePopulations(intToCam, intToProportion, filter) 390 // Map each color to the percentage of the image with its hue. 391 val intToHueProportion = wallpaperColors.allColors.mapValues { 392 val cam = intToCam[it.key]!! 393 val hue = cam.hue.roundToInt() 394 var proportion = 0.0 395 for (i in hue - 15..hue + 15) { 396 proportion += hueProportions[wrapDegrees(i)] 397 } 398 proportion 399 } 400 // Remove any inappropriate seed colors. For example, low chroma colors look grayscale 401 // raising their chroma will turn them to a much louder color that may not have been 402 // in the image. 403 val filteredIntToCam = if (!filter) intToCam else (intToCam.filter { 404 val cam = it.value 405 val proportion = intToHueProportion[it.key]!! 406 cam.chroma >= MIN_CHROMA && 407 (totalPopulationMeaningless || proportion > 0.01) 408 }) 409 // Sort the colors by score, from high to low. 410 val intToScoreIntermediate = filteredIntToCam.mapValues { 411 score(it.value, intToHueProportion[it.key]!!) 412 } 413 val intToScore = intToScoreIntermediate.entries.toMutableList() 414 intToScore.sortByDescending { it.value } 415 416 // Go through the colors, from high score to low score. 417 // If the color is distinct in hue from colors picked so far, pick the color. 418 // Iteratively decrease the amount of hue distinctness required, thus ensuring we 419 // maximize difference between colors. 420 val minimumHueDistance = 15 421 val seeds = mutableListOf<Int>() 422 maximizeHueDistance@ for (i in 90 downTo minimumHueDistance step 1) { 423 seeds.clear() 424 for (entry in intToScore) { 425 val int = entry.key 426 val existingSeedNearby = seeds.find { 427 val hueA = intToCam[int]!!.hue 428 val hueB = intToCam[it]!!.hue 429 hueDiff(hueA, hueB) < i 430 } != null 431 if (existingSeedNearby) { 432 continue 433 } 434 seeds.add(int) 435 if (seeds.size >= 4) { 436 break@maximizeHueDistance 437 } 438 } 439 } 440 441 if (seeds.isEmpty()) { 442 // Use gBlue 500 if there are 0 colors 443 seeds.add(GOOGLE_BLUE) 444 } 445 446 return seeds 447 } 448 wrapDegreesnull449 private fun wrapDegrees(degrees: Int): Int { 450 return when { 451 degrees < 0 -> { 452 (degrees % 360) + 360 453 } 454 degrees >= 360 -> { 455 degrees % 360 456 } 457 else -> { 458 degrees 459 } 460 } 461 } 462 wrapDegreesDoublenull463 public fun wrapDegreesDouble(degrees: Double): Double { 464 return when { 465 degrees < 0 -> { 466 (degrees % 360) + 360 467 } 468 degrees >= 360 -> { 469 degrees % 360 470 } 471 else -> { 472 degrees 473 } 474 } 475 } 476 hueDiffnull477 private fun hueDiff(a: Float, b: Float): Float { 478 return 180f - ((a - b).absoluteValue - 180f).absoluteValue 479 } 480 stringForColornull481 private fun stringForColor(color: Int): String { 482 val width = 4 483 val hct = Cam.fromInt(color) 484 val h = "H${hct.hue.roundToInt().toString().padEnd(width)}" 485 val c = "C${hct.chroma.roundToInt().toString().padEnd(width)}" 486 val t = "T${CamUtils.lstarFromInt(color).roundToInt().toString().padEnd(width)}" 487 val hex = Integer.toHexString(color and 0xffffff).padStart(6, '0').uppercase() 488 return "$h$c$t = #$hex" 489 } 490 humanReadablenull491 private fun humanReadable(paletteName: String, colors: List<Int>): String { 492 return "$paletteName\n" + colors.map { 493 stringForColor(it) 494 }.joinToString(separator = "\n") { it } 495 } 496 scorenull497 private fun score(cam: Cam, proportion: Double): Double { 498 val proportionScore = 0.7 * 100.0 * proportion 499 val chromaScore = if (cam.chroma < ACCENT1_CHROMA) 0.1 * (cam.chroma - ACCENT1_CHROMA) 500 else 0.3 * (cam.chroma - ACCENT1_CHROMA) 501 return chromaScore + proportionScore 502 } 503 huePopulationsnull504 private fun huePopulations( 505 camByColor: Map<Int, Cam>, 506 populationByColor: Map<Int, Double>, 507 filter: Boolean = true 508 ): List<Double> { 509 val huePopulation = List(size = 360, init = { 0.0 }).toMutableList() 510 511 for (entry in populationByColor.entries) { 512 val population = populationByColor[entry.key]!! 513 val cam = camByColor[entry.key]!! 514 val hue = cam.hue.roundToInt() % 360 515 if (filter && cam.chroma <= MIN_CHROMA) { 516 continue 517 } 518 huePopulation[hue] = huePopulation[hue] + population 519 } 520 521 return huePopulation 522 } 523 } 524 } 525