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 }