1 /*
2  * 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.lstarFromInt
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 ACCENT2_CHROMA = 16.0f
32 const val ACCENT3_CHROMA = 32.0f
33 const val ACCENT3_HUE_SHIFT = 60.0f
34 
35 const val NEUTRAL1_CHROMA = 4.0f
36 const val NEUTRAL2_CHROMA = 8.0f
37 
38 const val GOOGLE_BLUE = 0xFF1b6ef3.toInt()
39 
40 const val MIN_CHROMA = 5
41 
42 public class ColorScheme(@ColorInt seed: Int, val darkTheme: Boolean) {
43 
44     val accent1: List<Int>
45     val accent2: List<Int>
46     val accent3: List<Int>
47     val neutral1: List<Int>
48     val neutral2: List<Int>
49 
50     constructor(wallpaperColors: WallpaperColors, darkTheme: Boolean):
51             this(getSeedColor(wallpaperColors), darkTheme)
52 
53     val allAccentColors: List<Int>
54         get() {
55             val allColors = mutableListOf<Int>()
56             allColors.addAll(accent1)
57             allColors.addAll(accent2)
58             allColors.addAll(accent3)
59             return allColors
60         }
61 
62     val allNeutralColors: List<Int>
63         get() {
64             val allColors = mutableListOf<Int>()
65             allColors.addAll(neutral1)
66             allColors.addAll(neutral2)
67             return allColors
68         }
69 
70     val backgroundColor
71         get() = ColorUtils.setAlphaComponent(if (darkTheme) neutral1[8] else neutral1[0], 0xFF)
72 
73     val accentColor
74         get() = ColorUtils.setAlphaComponent(if (darkTheme) accent1[2] else accent1[6], 0xFF)
75 
76     init {
77         val proposedSeedCam = Cam.fromInt(seed)
78         val seedArgb = if (seed == Color.TRANSPARENT) {
79             GOOGLE_BLUE
80         } else if (proposedSeedCam.chroma < 5) {
81             GOOGLE_BLUE
82         } else {
83             seed
84         }
85         val camSeed = Cam.fromInt(seedArgb)
86         val hue = camSeed.hue
87         val chroma = camSeed.chroma.coerceAtLeast(ACCENT1_CHROMA)
88         val tertiaryHue = wrapDegrees((hue + ACCENT3_HUE_SHIFT).toInt())
89         accent1 = Shades.of(hue, chroma).toList()
90         accent2 = Shades.of(hue, ACCENT2_CHROMA).toList()
91         accent3 = Shades.of(tertiaryHue.toFloat(), ACCENT3_CHROMA).toList()
92         neutral1 = Shades.of(hue, NEUTRAL1_CHROMA).toList()
93         neutral2 = Shades.of(hue, NEUTRAL2_CHROMA).toList()
94     }
95 
96     override fun toString(): String {
97         return "ColorScheme {\n" +
98                 "  neutral1: ${humanReadable(neutral1)}\n" +
99                 "  neutral2: ${humanReadable(neutral2)}\n" +
100                 "  accent1: ${humanReadable(accent1)}\n" +
101                 "  accent2: ${humanReadable(accent2)}\n" +
102                 "  accent3: ${humanReadable(accent3)}\n" +
103                 "}"
104     }
105 
106     companion object {
107         /**
108          * Identifies a color to create a color scheme from.
109          *
110          * @param wallpaperColors Colors extracted from an image via quantization.
111          * @return ARGB int representing the color
112          */
113         @JvmStatic
114         @ColorInt
115         fun getSeedColor(wallpaperColors: WallpaperColors): Int {
116             return getSeedColors(wallpaperColors).first()
117         }
118 
119         /**
120          * Filters and ranks colors from WallpaperColors.
121          *
122          * @param wallpaperColors Colors extracted from an image via quantization.
123          * @return List of ARGB ints, ordered from highest scoring to lowest.
124          */
125         @JvmStatic
126         fun getSeedColors(wallpaperColors: WallpaperColors): List<Int> {
127             val totalPopulation = wallpaperColors.allColors.values.reduce { a, b -> a + b }
128                     .toDouble()
129             val totalPopulationMeaningless = (totalPopulation == 0.0)
130             if (totalPopulationMeaningless) {
131                 // WallpaperColors with a population of 0 indicate the colors didn't come from
132                 // quantization. Instead of scoring, trust the ordering of the provided primary
133                 // secondary/tertiary colors.
134                 //
135                 // In this case, the colors are usually from a Live Wallpaper.
136                 val distinctColors = wallpaperColors.mainColors.map {
137                     it.toArgb()
138                 }.distinct().filter {
139                     Cam.fromInt(it).chroma >= MIN_CHROMA
140                 }.toList()
141 
142                 if (distinctColors.isEmpty()) {
143                     return listOf(GOOGLE_BLUE)
144                 }
145                 return distinctColors
146             }
147 
148             val intToProportion = wallpaperColors.allColors.mapValues {
149                 it.value.toDouble() / totalPopulation
150             }
151             val intToCam = wallpaperColors.allColors.mapValues { Cam.fromInt(it.key) }
152 
153             // Get an array with 360 slots. A slot contains the percentage of colors with that hue.
154             val hueProportions = huePopulations(intToCam, intToProportion)
155             // Map each color to the percentage of the image with its hue.
156             val intToHueProportion = wallpaperColors.allColors.mapValues {
157                 val cam = intToCam[it.key]!!
158                 val hue = cam.hue.roundToInt()
159                 var proportion = 0.0
160                 for (i in hue - 15..hue + 15) {
161                     proportion += hueProportions[wrapDegrees(i)]
162                 }
163                 proportion
164             }
165             // Remove any inappropriate seed colors. For example, low chroma colors look grayscale
166             // raising their chroma will turn them to a much louder color that may not have been
167             // in the image.
168             val filteredIntToCam = intToCam.filter {
169                 val cam = it.value
170                 val lstar = lstarFromInt(it.key)
171                 val proportion = intToHueProportion[it.key]!!
172                 cam.chroma >= MIN_CHROMA &&
173                         (totalPopulationMeaningless || proportion > 0.01)
174             }
175             // Sort the colors by score, from high to low.
176             val intToScoreIntermediate = filteredIntToCam.mapValues {
177                 score(it.value, intToHueProportion[it.key]!!)
178             }
179             val intToScore = intToScoreIntermediate.entries.toMutableList()
180             intToScore.sortByDescending { it.value }
181 
182             // Go through the colors, from high score to low score.
183             // If the color is distinct in hue from colors picked so far, pick the color.
184             // Iteratively decrease the amount of hue distinctness required, thus ensuring we
185             // maximize difference between colors.
186             val minimumHueDistance = 15
187             val seeds = mutableListOf<Int>()
188             maximizeHueDistance@ for (i in 90 downTo minimumHueDistance step 1) {
189                 seeds.clear()
190                 for (entry in intToScore) {
191                     val int = entry.key
192                     val existingSeedNearby = seeds.find {
193                         val hueA = intToCam[int]!!.hue
194                         val hueB = intToCam[it]!!.hue
195                         hueDiff(hueA, hueB) < i } != null
196                     if (existingSeedNearby) {
197                         continue
198                     }
199                     seeds.add(int)
200                     if (seeds.size >= 4) {
201                         break@maximizeHueDistance
202                     }
203                 }
204             }
205 
206             if (seeds.isEmpty()) {
207                 // Use gBlue 500 if there are 0 colors
208                 seeds.add(GOOGLE_BLUE)
209             }
210 
211             return seeds
212         }
213 
214         private fun wrapDegrees(degrees: Int): Int {
215             return when {
216                 degrees < 0 -> {
217                     (degrees % 360) + 360
218                 }
219                 degrees >= 360 -> {
220                     degrees % 360
221                 }
222                 else -> {
223                     degrees
224                 }
225             }
226         }
227 
228         private fun hueDiff(a: Float, b: Float): Float {
229             return 180f - ((a - b).absoluteValue - 180f).absoluteValue
230         }
231 
232         private fun humanReadable(colors: List<Int>): String {
233             return colors.joinToString { "#" + Integer.toHexString(it) }
234         }
235 
236         private fun score(cam: Cam, proportion: Double): Double {
237             val proportionScore = 0.7 * 100.0 * proportion
238             val chromaScore = if (cam.chroma < ACCENT1_CHROMA) 0.1 * (cam.chroma - ACCENT1_CHROMA)
239             else 0.3 * (cam.chroma - ACCENT1_CHROMA)
240             return chromaScore + proportionScore
241         }
242 
243         private fun huePopulations(
244             camByColor: Map<Int, Cam>,
245             populationByColor: Map<Int, Double>
246         ): List<Double> {
247             val huePopulation = List(size = 360, init = { 0.0 }).toMutableList()
248 
249             for (entry in populationByColor.entries) {
250                 val population = populationByColor[entry.key]!!
251                 val cam = camByColor[entry.key]!!
252                 val hue = cam.hue.roundToInt() % 360
253                 if (cam.chroma <= MIN_CHROMA) {
254                     continue
255                 }
256                 huePopulation[hue] = huePopulation[hue] + population
257             }
258 
259             return huePopulation
260         }
261     }
262 }