• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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