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
25 import kotlin.math.absoluteValue
26 import kotlin.math.max
27 import kotlin.math.min
28 import kotlin.math.roundToInt
29 
30 const val TAG = "ColorScheme"
31 
32 const val ACCENT1_CHROMA = 48.0f
33 const val GOOGLE_BLUE = 0xFF1b6ef3.toInt()
34 const val MIN_CHROMA = 5
35 
36 internal interface Hue {
37     fun get(sourceColor: Cam): Double
38 
39     /**
40      * Given a hue, and a mapping of hues to hue rotations, find which hues in the mapping the hue
41      * fall betweens, and use the hue rotation of the lower hue.
42      *
43      * @param sourceHue hue of source color
44      * @param hueAndRotations list of pairs, where the first item in a pair is a hue, and the second
45      *   item in the pair is a hue rotation that should be applied
46      */
47     fun getHueRotation(sourceHue: Float, hueAndRotations: List<Pair<Int, Int>>): Double {
48         val sanitizedSourceHue = (if (sourceHue < 0 || sourceHue >= 360) 0 else sourceHue).toFloat()
49         for (i in 0..hueAndRotations.size - 2) {
50             val thisHue = hueAndRotations[i].first.toFloat()
51             val nextHue = hueAndRotations[i + 1].first.toFloat()
52             if (thisHue <= sanitizedSourceHue && sanitizedSourceHue < nextHue) {
53                 return ColorScheme.wrapDegreesDouble(
54                     sanitizedSourceHue.toDouble() + hueAndRotations[i].second
55                 )
56             }
57         }
58 
59         // If this statement executes, something is wrong, there should have been a rotation
60         // found using the arrays.
61         return sourceHue.toDouble()
62     }
63 }
64 
65 internal class HueSource : Hue {
66     override fun get(sourceColor: Cam): Double {
67         return sourceColor.hue.toDouble()
68     }
69 }
70 
71 internal class HueAdd(val amountDegrees: Double) : Hue {
72     override fun get(sourceColor: Cam): Double {
73         return ColorScheme.wrapDegreesDouble(sourceColor.hue.toDouble() + amountDegrees)
74     }
75 }
76 
77 internal class HueSubtract(val amountDegrees: Double) : Hue {
78     override fun get(sourceColor: Cam): Double {
79         return ColorScheme.wrapDegreesDouble(sourceColor.hue.toDouble() - amountDegrees)
80     }
81 }
82 
83 internal class HueVibrantSecondary() : Hue {
84     val hueToRotations =
85         listOf(
86             Pair(0, 18),
87             Pair(41, 15),
88             Pair(61, 10),
89             Pair(101, 12),
90             Pair(131, 15),
91             Pair(181, 18),
92             Pair(251, 15),
93             Pair(301, 12),
94             Pair(360, 12)
95         )
96 
97     override fun get(sourceColor: Cam): Double {
98         return getHueRotation(sourceColor.hue, hueToRotations)
99     }
100 }
101 
102 internal class HueVibrantTertiary() : Hue {
103     val hueToRotations =
104         listOf(
105             Pair(0, 35),
106             Pair(41, 30),
107             Pair(61, 20),
108             Pair(101, 25),
109             Pair(131, 30),
110             Pair(181, 35),
111             Pair(251, 30),
112             Pair(301, 25),
113             Pair(360, 25)
114         )
115 
116     override fun get(sourceColor: Cam): Double {
117         return getHueRotation(sourceColor.hue, hueToRotations)
118     }
119 }
120 
121 internal class HueExpressiveSecondary() : Hue {
122     val hueToRotations =
123         listOf(
124             Pair(0, 45),
125             Pair(21, 95),
126             Pair(51, 45),
127             Pair(121, 20),
128             Pair(151, 45),
129             Pair(191, 90),
130             Pair(271, 45),
131             Pair(321, 45),
132             Pair(360, 45)
133         )
134 
135     override fun get(sourceColor: Cam): Double {
136         return getHueRotation(sourceColor.hue, hueToRotations)
137     }
138 }
139 
140 internal class HueExpressiveTertiary() : Hue {
141     val hueToRotations =
142         listOf(
143             Pair(0, 120),
144             Pair(21, 120),
145             Pair(51, 20),
146             Pair(121, 45),
147             Pair(151, 20),
148             Pair(191, 15),
149             Pair(271, 20),
150             Pair(321, 120),
151             Pair(360, 120)
152         )
153 
154     override fun get(sourceColor: Cam): Double {
155         return getHueRotation(sourceColor.hue, hueToRotations)
156     }
157 }
158 
159 internal interface Chroma {
160     fun get(sourceColor: Cam): Double
161 
162     companion object {
163         val MAX_VALUE = 120.0
164         val MIN_VALUE = 0.0
165     }
166 }
167 
168 internal class ChromaMaxOut : Chroma {
169     override fun get(sourceColor: Cam): Double {
170         // Intentionally high. Gamut mapping from impossible HCT to sRGB will ensure that
171         // the maximum chroma is reached, even if lower than this constant.
172         return Chroma.MAX_VALUE + 10.0
173     }
174 }
175 
176 internal class ChromaMultiple(val multiple: Double) : Chroma {
177     override fun get(sourceColor: Cam): Double {
178         return sourceColor.chroma * multiple
179     }
180 }
181 
182 internal class ChromaAdd(val amount: Double) : Chroma {
183     override fun get(sourceColor: Cam): Double {
184         return sourceColor.chroma + amount
185     }
186 }
187 
188 internal class ChromaBound(
189     val baseChroma: Chroma,
190     val minVal: Double,
191     val maxVal: Double,
192 ) : Chroma {
193     override fun get(sourceColor: Cam): Double {
194         val result = baseChroma.get(sourceColor)
195         return min(max(result, minVal), maxVal)
196     }
197 }
198 
199 internal class ChromaConstant(val chroma: Double) : Chroma {
200     override fun get(sourceColor: Cam): Double {
201         return chroma
202     }
203 }
204 
205 internal class ChromaSource : Chroma {
206     override fun get(sourceColor: Cam): Double {
207         return sourceColor.chroma.toDouble()
208     }
209 }
210 
211 internal class TonalSpec(val hue: Hue = HueSource(), val chroma: Chroma) {
212     fun shades(sourceColor: Cam): List<Int> {
213         val hue = hue.get(sourceColor)
214         val chroma = chroma.get(sourceColor)
215         return Shades.of(hue.toFloat(), chroma.toFloat()).toList()
216     }
217 
218     fun getAtTone(sourceColor: Cam, tone: Float): Int {
219         val hue = hue.get(sourceColor)
220         val chroma = chroma.get(sourceColor)
221         return ColorUtils.CAMToColor(hue.toFloat(), chroma.toFloat(), (1000f - tone) / 10f)
222     }
223 }
224 
225 internal class CoreSpec(
226     val a1: TonalSpec,
227     val a2: TonalSpec,
228     val a3: TonalSpec,
229     val n1: TonalSpec,
230     val n2: TonalSpec
231 )
232 
233 enum class Style(internal val coreSpec: CoreSpec) {
234     SPRITZ(
235         CoreSpec(
236             a1 = TonalSpec(HueSource(), ChromaConstant(12.0)),
237             a2 = TonalSpec(HueSource(), ChromaConstant(8.0)),
238             a3 = TonalSpec(HueSource(), ChromaConstant(16.0)),
239             n1 = TonalSpec(HueSource(), ChromaConstant(2.0)),
240             n2 = TonalSpec(HueSource(), ChromaConstant(2.0))
241         )
242     ),
243     TONAL_SPOT(
244         CoreSpec(
245             a1 = TonalSpec(HueSource(), ChromaConstant(36.0)),
246             a2 = TonalSpec(HueSource(), ChromaConstant(16.0)),
247             a3 = TonalSpec(HueAdd(60.0), ChromaConstant(24.0)),
248             n1 = TonalSpec(HueSource(), ChromaConstant(6.0)),
249             n2 = TonalSpec(HueSource(), ChromaConstant(8.0))
250         )
251     ),
252     VIBRANT(
253         CoreSpec(
254             a1 = TonalSpec(HueSource(), ChromaMaxOut()),
255             a2 = TonalSpec(HueVibrantSecondary(), ChromaConstant(24.0)),
256             a3 = TonalSpec(HueVibrantTertiary(), ChromaConstant(32.0)),
257             n1 = TonalSpec(HueSource(), ChromaConstant(10.0)),
258             n2 = TonalSpec(HueSource(), ChromaConstant(12.0))
259         )
260     ),
261     EXPRESSIVE(
262         CoreSpec(
263             a1 = TonalSpec(HueAdd(240.0), ChromaConstant(40.0)),
264             a2 = TonalSpec(HueExpressiveSecondary(), ChromaConstant(24.0)),
265             a3 = TonalSpec(HueExpressiveTertiary(), ChromaConstant(32.0)),
266             n1 = TonalSpec(HueAdd(15.0), ChromaConstant(8.0)),
267             n2 = TonalSpec(HueAdd(15.0), ChromaConstant(12.0))
268         )
269     ),
270     RAINBOW(
271         CoreSpec(
272             a1 = TonalSpec(HueSource(), ChromaConstant(48.0)),
273             a2 = TonalSpec(HueSource(), ChromaConstant(16.0)),
274             a3 = TonalSpec(HueAdd(60.0), ChromaConstant(24.0)),
275             n1 = TonalSpec(HueSource(), ChromaConstant(0.0)),
276             n2 = TonalSpec(HueSource(), ChromaConstant(0.0))
277         )
278     ),
279     FRUIT_SALAD(
280         CoreSpec(
281             a1 = TonalSpec(HueSubtract(50.0), ChromaConstant(48.0)),
282             a2 = TonalSpec(HueSubtract(50.0), ChromaConstant(36.0)),
283             a3 = TonalSpec(HueSource(), ChromaConstant(36.0)),
284             n1 = TonalSpec(HueSource(), ChromaConstant(10.0)),
285             n2 = TonalSpec(HueSource(), ChromaConstant(16.0))
286         )
287     ),
288     CONTENT(
289         CoreSpec(
290             a1 = TonalSpec(HueSource(), ChromaSource()),
291             a2 = TonalSpec(HueSource(), ChromaMultiple(0.33)),
292             a3 = TonalSpec(HueSource(), ChromaMultiple(0.66)),
293             n1 = TonalSpec(HueSource(), ChromaMultiple(0.0833)),
294             n2 = TonalSpec(HueSource(), ChromaMultiple(0.1666))
295         )
296     ),
297     MONOCHROMATIC(
298         CoreSpec(
299             a1 = TonalSpec(HueSource(), ChromaConstant(.0)),
300             a2 = TonalSpec(HueSource(), ChromaConstant(.0)),
301             a3 = TonalSpec(HueSource(), ChromaConstant(.0)),
302             n1 = TonalSpec(HueSource(), ChromaConstant(.0)),
303             n2 = TonalSpec(HueSource(), ChromaConstant(.0))
304         )
305     ),
306     CLOCK(
307         CoreSpec(
308             a1 = TonalSpec(HueSource(), ChromaBound(ChromaSource(), 20.0, Chroma.MAX_VALUE)),
309             a2 = TonalSpec(HueAdd(10.0), ChromaBound(ChromaMultiple(0.85), 17.0, 40.0)),
310             a3 = TonalSpec(HueAdd(20.0), ChromaBound(ChromaAdd(20.0), 50.0, Chroma.MAX_VALUE)),
311 
312             // Not Used
313             n1 = TonalSpec(HueSource(), ChromaConstant(0.0)),
314             n2 = TonalSpec(HueSource(), ChromaConstant(0.0))
315         )
316     ),
317     CLOCK_VIBRANT(
318         CoreSpec(
319             a1 = TonalSpec(HueSource(), ChromaBound(ChromaSource(), 70.0, Chroma.MAX_VALUE)),
320             a2 = TonalSpec(HueAdd(20.0), ChromaBound(ChromaSource(), 70.0, Chroma.MAX_VALUE)),
321             a3 = TonalSpec(HueAdd(60.0), ChromaBound(ChromaSource(), 70.0, Chroma.MAX_VALUE)),
322 
323             // Not Used
324             n1 = TonalSpec(HueSource(), ChromaConstant(0.0)),
325             n2 = TonalSpec(HueSource(), ChromaConstant(0.0))
326         )
327     )
328 }
329 
330 class TonalPalette
331 internal constructor(
332     private val spec: TonalSpec,
333     seedColor: Int,
334 ) {
335     val seedCam: Cam = Cam.fromInt(seedColor)
336     val allShades: List<Int> = spec.shades(seedCam)
337     val allShadesMapped: Map<Int, Int> = SHADE_KEYS.zip(allShades).toMap()
338     val baseColor: Int
339 
340     init {
341         val h = spec.hue.get(seedCam).toFloat()
342         val c = spec.chroma.get(seedCam).toFloat()
343         baseColor = ColorUtils.CAMToColor(h, c, CamUtils.lstarFromInt(seedColor))
344     }
345 
346     // Dynamically computed tones across the full range from 0 to 1000
347     fun getAtTone(tone: Float) = spec.getAtTone(seedCam, tone)
348 
349     // Predefined & precomputed tones
350     val s10: Int
351         get() = this.allShades[0]
352     val s50: Int
353         get() = this.allShades[1]
354     val s100: Int
355         get() = this.allShades[2]
356     val s200: Int
357         get() = this.allShades[3]
358     val s300: Int
359         get() = this.allShades[4]
360     val s400: Int
361         get() = this.allShades[5]
362     val s500: Int
363         get() = this.allShades[6]
364     val s600: Int
365         get() = this.allShades[7]
366     val s700: Int
367         get() = this.allShades[8]
368     val s800: Int
369         get() = this.allShades[9]
370     val s900: Int
371         get() = this.allShades[10]
372     val s1000: Int
373         get() = this.allShades[11]
374 
375     companion object {
376         val SHADE_KEYS = listOf(10, 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000)
377     }
378 }
379 
380 @Deprecated("Please use com.google.ux.material.libmonet.dynamiccolor.MaterialDynamicColors " +
381         "instead")
382 class ColorScheme(
383     @ColorInt val seed: Int,
384     val darkTheme: Boolean,
385     val style: Style = Style.TONAL_SPOT
386 ) {
387 
388     val accent1: TonalPalette
389     val accent2: TonalPalette
390     val accent3: TonalPalette
391     val neutral1: TonalPalette
392     val neutral2: TonalPalette
393 
394     constructor(@ColorInt seed: Int, darkTheme: Boolean) : this(seed, darkTheme, Style.TONAL_SPOT)
395 
396     @JvmOverloads
397     constructor(
398         wallpaperColors: WallpaperColors,
399         darkTheme: Boolean,
400         style: Style = Style.TONAL_SPOT
401     ) : this(getSeedColor(wallpaperColors, style != Style.CONTENT), darkTheme, style)
402 
403     val allHues: List<TonalPalette>
404         get() {
405             return listOf(accent1, accent2, accent3, neutral1, neutral2)
406         }
407 
408     val allAccentColors: List<Int>
409         get() {
410             val allColors = mutableListOf<Int>()
411             allColors.addAll(accent1.allShades)
412             allColors.addAll(accent2.allShades)
413             allColors.addAll(accent3.allShades)
414             return allColors
415         }
416 
417     val allNeutralColors: List<Int>
418         get() {
419             val allColors = mutableListOf<Int>()
420             allColors.addAll(neutral1.allShades)
421             allColors.addAll(neutral2.allShades)
422             return allColors
423         }
424 
425     val backgroundColor
426         get() = ColorUtils.setAlphaComponent(if (darkTheme) neutral1.s700 else neutral1.s10, 0xFF)
427 
428     val accentColor
429         get() = ColorUtils.setAlphaComponent(if (darkTheme) accent1.s100 else accent1.s500, 0xFF)
430 
431     init {
432         val proposedSeedCam = Cam.fromInt(seed)
433         val seedArgb =
434             if (seed == Color.TRANSPARENT) {
435                 GOOGLE_BLUE
436             } else if (style != Style.CONTENT && proposedSeedCam.chroma < 5) {
437                 GOOGLE_BLUE
438             } else {
439                 seed
440             }
441 
442         accent1 = TonalPalette(style.coreSpec.a1, seedArgb)
443         accent2 = TonalPalette(style.coreSpec.a2, seedArgb)
444         accent3 = TonalPalette(style.coreSpec.a3, seedArgb)
445         neutral1 = TonalPalette(style.coreSpec.n1, seedArgb)
446         neutral2 = TonalPalette(style.coreSpec.n2, seedArgb)
447     }
448 
449     val shadeCount
450         get() = this.accent1.allShades.size
451 
452     val seedTone: Float
453         get() = 1000f - CamUtils.lstarFromInt(seed) * 10f
454 
455     override fun toString(): String {
456         return "ColorScheme {\n" +
457             "  seed color: ${stringForColor(seed)}\n" +
458             "  style: $style\n" +
459             "  palettes: \n" +
460             "  ${humanReadable("PRIMARY", accent1.allShades)}\n" +
461             "  ${humanReadable("SECONDARY", accent2.allShades)}\n" +
462             "  ${humanReadable("TERTIARY", accent3.allShades)}\n" +
463             "  ${humanReadable("NEUTRAL", neutral1.allShades)}\n" +
464             "  ${humanReadable("NEUTRAL VARIANT", neutral2.allShades)}\n" +
465             "}"
466     }
467 
468     companion object {
469         /**
470          * Identifies a color to create a color scheme from.
471          *
472          * @param wallpaperColors Colors extracted from an image via quantization.
473          * @param filter If false, allow colors that have low chroma, creating grayscale themes.
474          * @return ARGB int representing the color
475          */
476         @JvmStatic
477         @JvmOverloads
478         @ColorInt
479         fun getSeedColor(wallpaperColors: WallpaperColors, filter: Boolean = true): Int {
480             return getSeedColors(wallpaperColors, filter).first()
481         }
482 
483         /**
484          * Filters and ranks colors from WallpaperColors.
485          *
486          * @param wallpaperColors Colors extracted from an image via quantization.
487          * @param filter If false, allow colors that have low chroma, creating grayscale themes.
488          * @return List of ARGB ints, ordered from highest scoring to lowest.
489          */
490         @JvmStatic
491         @JvmOverloads
492         fun getSeedColors(wallpaperColors: WallpaperColors, filter: Boolean = true): List<Int> {
493             val totalPopulation =
494                 wallpaperColors.allColors.values.reduce { a, b -> a + b }.toDouble()
495             val totalPopulationMeaningless = (totalPopulation == 0.0)
496             if (totalPopulationMeaningless) {
497                 // WallpaperColors with a population of 0 indicate the colors didn't come from
498                 // quantization. Instead of scoring, trust the ordering of the provided primary
499                 // secondary/tertiary colors.
500                 //
501                 // In this case, the colors are usually from a Live Wallpaper.
502                 val distinctColors =
503                     wallpaperColors.mainColors
504                         .map { it.toArgb() }
505                         .distinct()
506                         .filter {
507                             if (!filter) {
508                                 true
509                             } else {
510                                 Cam.fromInt(it).chroma >= MIN_CHROMA
511                             }
512                         }
513                         .toList()
514                 if (distinctColors.isEmpty()) {
515                     return listOf(GOOGLE_BLUE)
516                 }
517                 return distinctColors
518             }
519 
520             val intToProportion =
521                 wallpaperColors.allColors.mapValues { it.value.toDouble() / totalPopulation }
522             val intToCam = wallpaperColors.allColors.mapValues { Cam.fromInt(it.key) }
523 
524             // Get an array with 360 slots. A slot contains the percentage of colors with that hue.
525             val hueProportions = huePopulations(intToCam, intToProportion, filter)
526             // Map each color to the percentage of the image with its hue.
527             val intToHueProportion =
528                 wallpaperColors.allColors.mapValues {
529                     val cam = intToCam[it.key]!!
530                     val hue = cam.hue.roundToInt()
531                     var proportion = 0.0
532                     for (i in hue - 15..hue + 15) {
533                         proportion += hueProportions[wrapDegrees(i)]
534                     }
535                     proportion
536                 }
537             // Remove any inappropriate seed colors. For example, low chroma colors look grayscale
538             // raising their chroma will turn them to a much louder color that may not have been
539             // in the image.
540             val filteredIntToCam =
541                 if (!filter) intToCam
542                 else
543                     (intToCam.filter {
544                         val cam = it.value
545                         val proportion = intToHueProportion[it.key]!!
546                         cam.chroma >= MIN_CHROMA &&
547                             (totalPopulationMeaningless || proportion > 0.01)
548                     })
549             // Sort the colors by score, from high to low.
550             val intToScoreIntermediate =
551                 filteredIntToCam.mapValues { score(it.value, intToHueProportion[it.key]!!) }
552             val intToScore = intToScoreIntermediate.entries.toMutableList()
553             intToScore.sortByDescending { it.value }
554 
555             // Go through the colors, from high score to low score.
556             // If the color is distinct in hue from colors picked so far, pick the color.
557             // Iteratively decrease the amount of hue distinctness required, thus ensuring we
558             // maximize difference between colors.
559             val minimumHueDistance = 15
560             val seeds = mutableListOf<Int>()
561             maximizeHueDistance@ for (i in 90 downTo minimumHueDistance step 1) {
562                 seeds.clear()
563                 for (entry in intToScore) {
564                     val int = entry.key
565                     val existingSeedNearby =
566                         seeds.find {
567                             val hueA = intToCam[int]!!.hue
568                             val hueB = intToCam[it]!!.hue
569                             hueDiff(hueA, hueB) < i
570                         } != null
571                     if (existingSeedNearby) {
572                         continue
573                     }
574                     seeds.add(int)
575                     if (seeds.size >= 4) {
576                         break@maximizeHueDistance
577                     }
578                 }
579             }
580 
581             if (seeds.isEmpty()) {
582                 // Use gBlue 500 if there are 0 colors
583                 seeds.add(GOOGLE_BLUE)
584             }
585 
586             return seeds
587         }
588 
589         private fun wrapDegrees(degrees: Int): Int {
590             return when {
591                 degrees < 0 -> {
592                     (degrees % 360) + 360
593                 }
594                 degrees >= 360 -> {
595                     degrees % 360
596                 }
597                 else -> {
598                     degrees
599                 }
600             }
601         }
602 
603         public fun wrapDegreesDouble(degrees: Double): Double {
604             return when {
605                 degrees < 0 -> {
606                     (degrees % 360) + 360
607                 }
608                 degrees >= 360 -> {
609                     degrees % 360
610                 }
611                 else -> {
612                     degrees
613                 }
614             }
615         }
616 
617         private fun hueDiff(a: Float, b: Float): Float {
618             return 180f - ((a - b).absoluteValue - 180f).absoluteValue
619         }
620 
621         private fun stringForColor(color: Int): String {
622             val width = 4
623             val hct = Cam.fromInt(color)
624             val h = "H${hct.hue.roundToInt().toString().padEnd(width)}"
625             val c = "C${hct.chroma.roundToInt().toString().padEnd(width)}"
626             val t = "T${CamUtils.lstarFromInt(color).roundToInt().toString().padEnd(width)}"
627             val hex = Integer.toHexString(color and 0xffffff).padStart(6, '0').uppercase()
628             return "$h$c$t = #$hex"
629         }
630 
631         private fun humanReadable(paletteName: String, colors: List<Int>): String {
632             return "$paletteName\n" +
633                 colors.map { stringForColor(it) }.joinToString(separator = "\n") { it }
634         }
635 
636         private fun score(cam: Cam, proportion: Double): Double {
637             val proportionScore = 0.7 * 100.0 * proportion
638             val chromaScore =
639                 if (cam.chroma < ACCENT1_CHROMA) 0.1 * (cam.chroma - ACCENT1_CHROMA)
640                 else 0.3 * (cam.chroma - ACCENT1_CHROMA)
641             return chromaScore + proportionScore
642         }
643 
644         private fun huePopulations(
645             camByColor: Map<Int, Cam>,
646             populationByColor: Map<Int, Double>,
647             filter: Boolean = true
648         ): List<Double> {
649             val huePopulation = List(size = 360, init = { 0.0 }).toMutableList()
650 
651             for (entry in populationByColor.entries) {
652                 val population = populationByColor[entry.key]!!
653                 val cam = camByColor[entry.key]!!
654                 val hue = cam.hue.roundToInt() % 360
655                 if (filter && cam.chroma <= MIN_CHROMA) {
656                     continue
657                 }
658                 huePopulation[hue] = huePopulation[hue] + population
659             }
660 
661             return huePopulation
662         }
663     }
664 }
665