Additional Color Palettes

Bug: 274647325
Test: Checked new tonal specs
Change-Id: Iae852a80567e2f0420009eaac7022d3e8c194f48
This commit is contained in:
Hawkwood Glazier
2023-03-21 22:05:09 +00:00
parent c4310a6033
commit 57405bd0a5
2 changed files with 275 additions and 130 deletions

View File

@@ -23,6 +23,8 @@ import com.android.internal.graphics.ColorUtils
import com.android.internal.graphics.cam.Cam
import com.android.internal.graphics.cam.CamUtils
import kotlin.math.absoluteValue
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
const val TAG = "ColorScheme"
@@ -35,12 +37,12 @@ internal interface Hue {
fun get(sourceColor: Cam): Double
/**
* Given a hue, and a mapping of hues to hue rotations, find which hues in the mapping the
* hue fall betweens, and use the hue rotation of the lower hue.
* Given a hue, and a mapping of hues to hue rotations, find which hues in the mapping the hue
* fall betweens, and use the hue rotation of the lower hue.
*
* @param sourceHue hue of source color
* @param hueAndRotations list of pairs, where the first item in a pair is a hue, and the
* second item in the pair is a hue rotation that should be applied
* @param hueAndRotations list of pairs, where the first item in a pair is a hue, and the second
* item in the pair is a hue rotation that should be applied
*/
fun getHueRotation(sourceHue: Float, hueAndRotations: List<Pair<Int, Int>>): Double {
val sanitizedSourceHue = (if (sourceHue < 0 || sourceHue >= 360) 0 else sourceHue).toFloat()
@@ -48,8 +50,9 @@ internal interface Hue {
val thisHue = hueAndRotations[i].first.toFloat()
val nextHue = hueAndRotations[i + 1].first.toFloat()
if (thisHue <= sanitizedSourceHue && sanitizedSourceHue < nextHue) {
return ColorScheme.wrapDegreesDouble(sanitizedSourceHue.toDouble() +
hueAndRotations[i].second)
return ColorScheme.wrapDegreesDouble(
sanitizedSourceHue.toDouble() + hueAndRotations[i].second
)
}
}
@@ -78,8 +81,18 @@ internal class HueSubtract(val amountDegrees: Double) : Hue {
}
internal class HueVibrantSecondary() : Hue {
val hueToRotations = listOf(Pair(0, 18), Pair(41, 15), Pair(61, 10), Pair(101, 12),
Pair(131, 15), Pair(181, 18), Pair(251, 15), Pair(301, 12), Pair(360, 12))
val hueToRotations =
listOf(
Pair(0, 18),
Pair(41, 15),
Pair(61, 10),
Pair(101, 12),
Pair(131, 15),
Pair(181, 18),
Pair(251, 15),
Pair(301, 12),
Pair(360, 12)
)
override fun get(sourceColor: Cam): Double {
return getHueRotation(sourceColor.hue, hueToRotations)
@@ -87,8 +100,18 @@ internal class HueVibrantSecondary() : Hue {
}
internal class HueVibrantTertiary() : Hue {
val hueToRotations = listOf(Pair(0, 35), Pair(41, 30), Pair(61, 20), Pair(101, 25),
Pair(131, 30), Pair(181, 35), Pair(251, 30), Pair(301, 25), Pair(360, 25))
val hueToRotations =
listOf(
Pair(0, 35),
Pair(41, 30),
Pair(61, 20),
Pair(101, 25),
Pair(131, 30),
Pair(181, 35),
Pair(251, 30),
Pair(301, 25),
Pair(360, 25)
)
override fun get(sourceColor: Cam): Double {
return getHueRotation(sourceColor.hue, hueToRotations)
@@ -96,8 +119,18 @@ internal class HueVibrantTertiary() : Hue {
}
internal class HueExpressiveSecondary() : Hue {
val hueToRotations = listOf(Pair(0, 45), Pair(21, 95), Pair(51, 45), Pair(121, 20),
Pair(151, 45), Pair(191, 90), Pair(271, 45), Pair(321, 45), Pair(360, 45))
val hueToRotations =
listOf(
Pair(0, 45),
Pair(21, 95),
Pair(51, 45),
Pair(121, 20),
Pair(151, 45),
Pair(191, 90),
Pair(271, 45),
Pair(321, 45),
Pair(360, 45)
)
override fun get(sourceColor: Cam): Double {
return getHueRotation(sourceColor.hue, hueToRotations)
@@ -105,8 +138,18 @@ internal class HueExpressiveSecondary() : Hue {
}
internal class HueExpressiveTertiary() : Hue {
val hueToRotations = listOf(Pair(0, 120), Pair(21, 120), Pair(51, 20), Pair(121, 45),
Pair(151, 20), Pair(191, 15), Pair(271, 20), Pair(321, 120), Pair(360, 120))
val hueToRotations =
listOf(
Pair(0, 120),
Pair(21, 120),
Pair(51, 20),
Pair(121, 45),
Pair(151, 20),
Pair(191, 15),
Pair(271, 20),
Pair(321, 120),
Pair(360, 120)
)
override fun get(sourceColor: Cam): Double {
return getHueRotation(sourceColor.hue, hueToRotations)
@@ -115,13 +158,18 @@ internal class HueExpressiveTertiary() : Hue {
internal interface Chroma {
fun get(sourceColor: Cam): Double
companion object {
val MAX_VALUE = 120.0
val MIN_VALUE = 0.0
}
}
internal class ChromaMaxOut : Chroma {
override fun get(sourceColor: Cam): Double {
// Intentionally high. Gamut mapping from impossible HCT to sRGB will ensure that
// the maximum chroma is reached, even if lower than this constant.
return 130.0
return Chroma.MAX_VALUE + 10.0
}
}
@@ -131,6 +179,23 @@ internal class ChromaMultiple(val multiple: Double) : Chroma {
}
}
internal class ChromaAdd(val amount: Double) : Chroma {
override fun get(sourceColor: Cam): Double {
return sourceColor.chroma + amount
}
}
internal class ChromaBound(
val baseChroma: Chroma,
val minVal: Double,
val maxVal: Double,
) : Chroma {
override fun get(sourceColor: Cam): Double {
val result = baseChroma.get(sourceColor)
return min(max(result, minVal), maxVal)
}
}
internal class ChromaConstant(val chroma: Double) : Chroma {
override fun get(sourceColor: Cam): Double {
return chroma
@@ -149,109 +214,173 @@ internal class TonalSpec(val hue: Hue = HueSource(), val chroma: Chroma) {
val chroma = chroma.get(sourceColor)
return Shades.of(hue.toFloat(), chroma.toFloat()).toList()
}
fun getAtTone(sourceColor: Cam, tone: Float): Int {
val hue = hue.get(sourceColor)
val chroma = chroma.get(sourceColor)
return ColorUtils.CAMToColor(hue.toFloat(), chroma.toFloat(), (1000f - tone) / 10f)
}
}
internal class CoreSpec(
val a1: TonalSpec,
val a2: TonalSpec,
val a3: TonalSpec,
val n1: TonalSpec,
val n2: TonalSpec
val a1: TonalSpec,
val a2: TonalSpec,
val a3: TonalSpec,
val n1: TonalSpec,
val n2: TonalSpec
)
enum class Style(internal val coreSpec: CoreSpec) {
SPRITZ(CoreSpec(
SPRITZ(
CoreSpec(
a1 = TonalSpec(HueSource(), ChromaConstant(12.0)),
a2 = TonalSpec(HueSource(), ChromaConstant(8.0)),
a3 = TonalSpec(HueSource(), ChromaConstant(16.0)),
n1 = TonalSpec(HueSource(), ChromaConstant(2.0)),
n2 = TonalSpec(HueSource(), ChromaConstant(2.0))
)),
TONAL_SPOT(CoreSpec(
)
),
TONAL_SPOT(
CoreSpec(
a1 = TonalSpec(HueSource(), ChromaConstant(36.0)),
a2 = TonalSpec(HueSource(), ChromaConstant(16.0)),
a3 = TonalSpec(HueAdd(60.0), ChromaConstant(24.0)),
n1 = TonalSpec(HueSource(), ChromaConstant(6.0)),
n2 = TonalSpec(HueSource(), ChromaConstant(8.0))
)),
VIBRANT(CoreSpec(
)
),
VIBRANT(
CoreSpec(
a1 = TonalSpec(HueSource(), ChromaMaxOut()),
a2 = TonalSpec(HueVibrantSecondary(), ChromaConstant(24.0)),
a3 = TonalSpec(HueVibrantTertiary(), ChromaConstant(32.0)),
n1 = TonalSpec(HueSource(), ChromaConstant(10.0)),
n2 = TonalSpec(HueSource(), ChromaConstant(12.0))
)),
EXPRESSIVE(CoreSpec(
)
),
EXPRESSIVE(
CoreSpec(
a1 = TonalSpec(HueAdd(240.0), ChromaConstant(40.0)),
a2 = TonalSpec(HueExpressiveSecondary(), ChromaConstant(24.0)),
a3 = TonalSpec(HueExpressiveTertiary(), ChromaConstant(32.0)),
n1 = TonalSpec(HueAdd(15.0), ChromaConstant(8.0)),
n2 = TonalSpec(HueAdd(15.0), ChromaConstant(12.0))
)),
RAINBOW(CoreSpec(
)
),
RAINBOW(
CoreSpec(
a1 = TonalSpec(HueSource(), ChromaConstant(48.0)),
a2 = TonalSpec(HueSource(), ChromaConstant(16.0)),
a3 = TonalSpec(HueAdd(60.0), ChromaConstant(24.0)),
n1 = TonalSpec(HueSource(), ChromaConstant(0.0)),
n2 = TonalSpec(HueSource(), ChromaConstant(0.0))
)),
FRUIT_SALAD(CoreSpec(
)
),
FRUIT_SALAD(
CoreSpec(
a1 = TonalSpec(HueSubtract(50.0), ChromaConstant(48.0)),
a2 = TonalSpec(HueSubtract(50.0), ChromaConstant(36.0)),
a3 = TonalSpec(HueSource(), ChromaConstant(36.0)),
n1 = TonalSpec(HueSource(), ChromaConstant(10.0)),
n2 = TonalSpec(HueSource(), ChromaConstant(16.0))
)),
CONTENT(CoreSpec(
)
),
CONTENT(
CoreSpec(
a1 = TonalSpec(HueSource(), ChromaSource()),
a2 = TonalSpec(HueSource(), ChromaMultiple(0.33)),
a3 = TonalSpec(HueSource(), ChromaMultiple(0.66)),
n1 = TonalSpec(HueSource(), ChromaMultiple(0.0833)),
n2 = TonalSpec(HueSource(), ChromaMultiple(0.1666))
)),
MONOCHROMATIC(CoreSpec(
)
),
MONOCHROMATIC(
CoreSpec(
a1 = TonalSpec(HueSource(), ChromaConstant(.0)),
a2 = TonalSpec(HueSource(), ChromaConstant(.0)),
a3 = TonalSpec(HueSource(), ChromaConstant(.0)),
n1 = TonalSpec(HueSource(), ChromaConstant(.0)),
n2 = TonalSpec(HueSource(), ChromaConstant(.0))
)),
)
),
CLOCK(
CoreSpec(
a1 = TonalSpec(HueSource(), ChromaBound(ChromaSource(), 20.0, Chroma.MAX_VALUE)),
a2 = TonalSpec(HueAdd(10.0), ChromaBound(ChromaMultiple(0.85), 17.0, 40.0)),
a3 = TonalSpec(HueAdd(20.0), ChromaBound(ChromaAdd(20.0), 50.0, Chroma.MAX_VALUE)),
// Not Used
n1 = TonalSpec(HueSource(), ChromaConstant(0.0)),
n2 = TonalSpec(HueSource(), ChromaConstant(0.0))
)
),
CLOCK_VIBRANT(
CoreSpec(
a1 = TonalSpec(HueSource(), ChromaBound(ChromaSource(), 30.0, Chroma.MAX_VALUE)),
a2 = TonalSpec(HueAdd(20.0), ChromaBound(ChromaSource(), 30.0, Chroma.MAX_VALUE)),
a3 = TonalSpec(HueAdd(60.0), ChromaBound(ChromaSource(), 30.0, Chroma.MAX_VALUE)),
// Not Used
n1 = TonalSpec(HueSource(), ChromaConstant(0.0)),
n2 = TonalSpec(HueSource(), ChromaConstant(0.0))
)
)
}
class TonalPalette {
val shadeKeys = listOf(10, 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000)
val allShades: List<Int>
val allShadesMapped: Map<Int, Int>
class TonalPalette
internal constructor(
private val spec: TonalSpec,
seedColor: Int,
) {
val seedCam: Cam = Cam.fromInt(seedColor)
val allShades: List<Int> = spec.shades(seedCam)
val allShadesMapped: Map<Int, Int> = SHADE_KEYS.zip(allShades).toMap()
val baseColor: Int
internal constructor(spec: TonalSpec, seedColor: Int) {
val seedCam = Cam.fromInt(seedColor)
allShades = spec.shades(seedCam)
allShadesMapped = shadeKeys.zip(allShades).toMap()
init {
val h = spec.hue.get(seedCam).toFloat()
val c = spec.chroma.get(seedCam).toFloat()
baseColor = ColorUtils.CAMToColor(h, c, CamUtils.lstarFromInt(seedColor))
}
val s10: Int get() = this.allShades[0]
val s50: Int get() = this.allShades[1]
val s100: Int get() = this.allShades[2]
val s200: Int get() = this.allShades[3]
val s300: Int get() = this.allShades[4]
val s400: Int get() = this.allShades[5]
val s500: Int get() = this.allShades[6]
val s600: Int get() = this.allShades[7]
val s700: Int get() = this.allShades[8]
val s800: Int get() = this.allShades[9]
val s900: Int get() = this.allShades[10]
val s1000: Int get() = this.allShades[11]
// Dynamically computed tones across the full range from 0 to 1000
fun getAtTone(tone: Float) = spec.getAtTone(seedCam, tone)
// Predefined & precomputed tones
val s10: Int
get() = this.allShades[0]
val s50: Int
get() = this.allShades[1]
val s100: Int
get() = this.allShades[2]
val s200: Int
get() = this.allShades[3]
val s300: Int
get() = this.allShades[4]
val s400: Int
get() = this.allShades[5]
val s500: Int
get() = this.allShades[6]
val s600: Int
get() = this.allShades[7]
val s700: Int
get() = this.allShades[8]
val s800: Int
get() = this.allShades[9]
val s900: Int
get() = this.allShades[10]
val s1000: Int
get() = this.allShades[11]
companion object {
val SHADE_KEYS = listOf(10, 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000)
}
}
class ColorScheme(
@ColorInt val seed: Int,
val darkTheme: Boolean,
val style: Style = Style.TONAL_SPOT
@ColorInt val seed: Int,
val darkTheme: Boolean,
val style: Style = Style.TONAL_SPOT
) {
val accent1: TonalPalette
@@ -260,16 +389,14 @@ class ColorScheme(
val neutral1: TonalPalette
val neutral2: TonalPalette
constructor(@ColorInt seed: Int, darkTheme: Boolean) :
this(seed, darkTheme, Style.TONAL_SPOT)
constructor(@ColorInt seed: Int, darkTheme: Boolean) : this(seed, darkTheme, Style.TONAL_SPOT)
@JvmOverloads
constructor(
wallpaperColors: WallpaperColors,
darkTheme: Boolean,
style: Style = Style.TONAL_SPOT
) :
this(getSeedColor(wallpaperColors, style != Style.CONTENT), darkTheme, style)
wallpaperColors: WallpaperColors,
darkTheme: Boolean,
style: Style = Style.TONAL_SPOT
) : this(getSeedColor(wallpaperColors, style != Style.CONTENT), darkTheme, style)
val allHues: List<TonalPalette>
get() {
@@ -301,13 +428,14 @@ class ColorScheme(
init {
val proposedSeedCam = Cam.fromInt(seed)
val seedArgb = if (seed == Color.TRANSPARENT) {
GOOGLE_BLUE
} else if (style != Style.CONTENT && proposedSeedCam.chroma < 5) {
GOOGLE_BLUE
} else {
seed
}
val seedArgb =
if (seed == Color.TRANSPARENT) {
GOOGLE_BLUE
} else if (style != Style.CONTENT && proposedSeedCam.chroma < 5) {
GOOGLE_BLUE
} else {
seed
}
accent1 = TonalPalette(style.coreSpec.a1, seedArgb)
accent2 = TonalPalette(style.coreSpec.a2, seedArgb)
@@ -316,19 +444,23 @@ class ColorScheme(
neutral2 = TonalPalette(style.coreSpec.n2, seedArgb)
}
val shadeCount get() = this.accent1.allShades.size
val shadeCount
get() = this.accent1.allShades.size
val seedTone: Float
get() = 1000f - CamUtils.lstarFromInt(seed) * 10f
override fun toString(): String {
return "ColorScheme {\n" +
" seed color: ${stringForColor(seed)}\n" +
" style: $style\n" +
" palettes: \n" +
" ${humanReadable("PRIMARY", accent1.allShades)}\n" +
" ${humanReadable("SECONDARY", accent2.allShades)}\n" +
" ${humanReadable("TERTIARY", accent3.allShades)}\n" +
" ${humanReadable("NEUTRAL", neutral1.allShades)}\n" +
" ${humanReadable("NEUTRAL VARIANT", neutral2.allShades)}\n" +
"}"
" seed color: ${stringForColor(seed)}\n" +
" style: $style\n" +
" palettes: \n" +
" ${humanReadable("PRIMARY", accent1.allShades)}\n" +
" ${humanReadable("SECONDARY", accent2.allShades)}\n" +
" ${humanReadable("TERTIARY", accent3.allShades)}\n" +
" ${humanReadable("NEUTRAL", neutral1.allShades)}\n" +
" ${humanReadable("NEUTRAL VARIANT", neutral2.allShades)}\n" +
"}"
}
companion object {
@@ -356,8 +488,8 @@ class ColorScheme(
@JvmStatic
@JvmOverloads
fun getSeedColors(wallpaperColors: WallpaperColors, filter: Boolean = true): List<Int> {
val totalPopulation = wallpaperColors.allColors.values.reduce { a, b -> a + b }
.toDouble()
val totalPopulation =
wallpaperColors.allColors.values.reduce { a, b -> a + b }.toDouble()
val totalPopulationMeaningless = (totalPopulation == 0.0)
if (totalPopulationMeaningless) {
// WallpaperColors with a population of 0 indicate the colors didn't come from
@@ -365,51 +497,56 @@ class ColorScheme(
// secondary/tertiary colors.
//
// In this case, the colors are usually from a Live Wallpaper.
val distinctColors = wallpaperColors.mainColors.map {
it.toArgb()
}.distinct().filter {
if (!filter) {
true
} else {
Cam.fromInt(it).chroma >= MIN_CHROMA
}
}.toList()
val distinctColors =
wallpaperColors.mainColors
.map { it.toArgb() }
.distinct()
.filter {
if (!filter) {
true
} else {
Cam.fromInt(it).chroma >= MIN_CHROMA
}
}
.toList()
if (distinctColors.isEmpty()) {
return listOf(GOOGLE_BLUE)
}
return distinctColors
}
val intToProportion = wallpaperColors.allColors.mapValues {
it.value.toDouble() / totalPopulation
}
val intToProportion =
wallpaperColors.allColors.mapValues { it.value.toDouble() / totalPopulation }
val intToCam = wallpaperColors.allColors.mapValues { Cam.fromInt(it.key) }
// Get an array with 360 slots. A slot contains the percentage of colors with that hue.
val hueProportions = huePopulations(intToCam, intToProportion, filter)
// Map each color to the percentage of the image with its hue.
val intToHueProportion = wallpaperColors.allColors.mapValues {
val cam = intToCam[it.key]!!
val hue = cam.hue.roundToInt()
var proportion = 0.0
for (i in hue - 15..hue + 15) {
proportion += hueProportions[wrapDegrees(i)]
val intToHueProportion =
wallpaperColors.allColors.mapValues {
val cam = intToCam[it.key]!!
val hue = cam.hue.roundToInt()
var proportion = 0.0
for (i in hue - 15..hue + 15) {
proportion += hueProportions[wrapDegrees(i)]
}
proportion
}
proportion
}
// Remove any inappropriate seed colors. For example, low chroma colors look grayscale
// raising their chroma will turn them to a much louder color that may not have been
// in the image.
val filteredIntToCam = if (!filter) intToCam else (intToCam.filter {
val cam = it.value
val proportion = intToHueProportion[it.key]!!
cam.chroma >= MIN_CHROMA &&
(totalPopulationMeaningless || proportion > 0.01)
})
val filteredIntToCam =
if (!filter) intToCam
else
(intToCam.filter {
val cam = it.value
val proportion = intToHueProportion[it.key]!!
cam.chroma >= MIN_CHROMA &&
(totalPopulationMeaningless || proportion > 0.01)
})
// Sort the colors by score, from high to low.
val intToScoreIntermediate = filteredIntToCam.mapValues {
score(it.value, intToHueProportion[it.key]!!)
}
val intToScoreIntermediate =
filteredIntToCam.mapValues { score(it.value, intToHueProportion[it.key]!!) }
val intToScore = intToScoreIntermediate.entries.toMutableList()
intToScore.sortByDescending { it.value }
@@ -423,11 +560,12 @@ class ColorScheme(
seeds.clear()
for (entry in intToScore) {
val int = entry.key
val existingSeedNearby = seeds.find {
val hueA = intToCam[int]!!.hue
val hueB = intToCam[it]!!.hue
hueDiff(hueA, hueB) < i
} != null
val existingSeedNearby =
seeds.find {
val hueA = intToCam[int]!!.hue
val hueB = intToCam[it]!!.hue
hueDiff(hueA, hueB) < i
} != null
if (existingSeedNearby) {
continue
}
@@ -489,22 +627,22 @@ class ColorScheme(
}
private fun humanReadable(paletteName: String, colors: List<Int>): String {
return "$paletteName\n" + colors.map {
stringForColor(it)
}.joinToString(separator = "\n") { it }
return "$paletteName\n" +
colors.map { stringForColor(it) }.joinToString(separator = "\n") { it }
}
private fun score(cam: Cam, proportion: Double): Double {
val proportionScore = 0.7 * 100.0 * proportion
val chromaScore = if (cam.chroma < ACCENT1_CHROMA) 0.1 * (cam.chroma - ACCENT1_CHROMA)
else 0.3 * (cam.chroma - ACCENT1_CHROMA)
val chromaScore =
if (cam.chroma < ACCENT1_CHROMA) 0.1 * (cam.chroma - ACCENT1_CHROMA)
else 0.3 * (cam.chroma - ACCENT1_CHROMA)
return chromaScore + proportionScore
}
private fun huePopulations(
camByColor: Map<Int, Cam>,
populationByColor: Map<Int, Double>,
filter: Boolean = true
camByColor: Map<Int, Cam>,
populationByColor: Map<Int, Double>,
filter: Boolean = true
): List<Double> {
val huePopulation = List(size = 360, init = { 0.0 }).toMutableList()

View File

@@ -109,6 +109,9 @@ interface ClockEvents {
/** Call whenever the locale changes */
fun onLocaleChanged(locale: Locale) {}
val isReactiveToTone
get() = true
/** Call whenever the color palette should update */
fun onColorPaletteChanged(resources: Resources) {}
@@ -164,8 +167,12 @@ interface ClockFaceEvents {
val hasCustomWeatherDataDisplay: Boolean
get() = false
/** Region Darkness specific to the clock face */
fun onRegionDarknessChanged(isDark: Boolean) {}
/**
* Region Darkness specific to the clock face.
* - isRegionDark = dark theme -> clock should be light
* - !isRegionDark = light theme -> clock should be dark
*/
fun onRegionDarknessChanged(isRegionDark: Boolean) {}
/**
* Call whenever font settings change. Pass in a target font size in pixels. The specific clock