| /* |
| * Copyright (C) 2023 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package com.android.systemui.monet.score; |
| |
| import com.android.systemui.monet.hct.Cam16; |
| import com.android.systemui.monet.utils.ColorUtils; |
| import com.android.systemui.monet.utils.MathUtils; |
| |
| import java.util.ArrayList; |
| import java.util.Collections; |
| import java.util.Comparator; |
| import java.util.HashMap; |
| import java.util.List; |
| import java.util.Map; |
| |
| /** |
| * Given a large set of colors, remove colors that are unsuitable for a UI theme, and rank the rest |
| * based on suitability. |
| * |
| * <p>Enables use of a high cluster count for image quantization, thus ensuring colors aren't |
| * muddied, while curating the high cluster count to a much smaller number of appropriate choices. |
| */ |
| public final class Score { |
| private static final double CUTOFF_CHROMA = 15.; |
| private static final double CUTOFF_EXCITED_PROPORTION = 0.01; |
| private static final double CUTOFF_TONE = 10.; |
| private static final double TARGET_CHROMA = 48.; |
| private static final double WEIGHT_PROPORTION = 0.7; |
| private static final double WEIGHT_CHROMA_ABOVE = 0.3; |
| private static final double WEIGHT_CHROMA_BELOW = 0.1; |
| |
| private Score() { |
| } |
| |
| /** |
| * Given a map with keys of colors and values of how often the color appears, rank the colors |
| * based on suitability for being used for a UI theme. |
| * |
| * @param colorsToPopulation map with keys of colors and values of how often the color appears, |
| * usually from a source image. |
| * @return Colors sorted by suitability for a UI theme. The most suitable color is the first |
| * item, |
| * the least suitable is the last. There will always be at least one color returned. If all |
| * the input colors were not suitable for a theme, a default fallback color will be provided. |
| */ |
| public static List<Integer> score(Map<Integer, Integer> colorsToPopulation) { |
| // Determine the total count of all colors. |
| double populationSum = 0.; |
| for (Map.Entry<Integer, Integer> entry : colorsToPopulation.entrySet()) { |
| populationSum += entry.getValue(); |
| } |
| |
| // Turn the count of each color into a proportion by dividing by the total |
| // count. Also, fill a cache of CAM16 colors representing each color, and |
| // record the proportion of colors for each CAM16 hue. |
| Map<Integer, Cam16> colorsToCam = new HashMap<>(); |
| double[] hueProportions = new double[361]; |
| for (Map.Entry<Integer, Integer> entry : colorsToPopulation.entrySet()) { |
| int color = entry.getKey(); |
| double population = entry.getValue(); |
| double proportion = population / populationSum; |
| |
| Cam16 cam = Cam16.fromInt(color); |
| colorsToCam.put(color, cam); |
| |
| int hue = (int) Math.round(cam.getHue()); |
| hueProportions[hue] += proportion; |
| } |
| |
| // Determine the proportion of the colors around each color, by summing the |
| // proportions around each color's hue. |
| Map<Integer, Double> colorsToExcitedProportion = new HashMap<>(); |
| for (Map.Entry<Integer, Cam16> entry : colorsToCam.entrySet()) { |
| int color = entry.getKey(); |
| Cam16 cam = entry.getValue(); |
| int hue = (int) Math.round(cam.getHue()); |
| |
| double excitedProportion = 0.; |
| for (int j = (hue - 15); j < (hue + 15); j++) { |
| int neighborHue = MathUtils.sanitizeDegreesInt(j); |
| excitedProportion += hueProportions[neighborHue]; |
| } |
| |
| colorsToExcitedProportion.put(color, excitedProportion); |
| } |
| |
| // Score the colors by their proportion, as well as how chromatic they are. |
| Map<Integer, Double> colorsToScore = new HashMap<>(); |
| for (Map.Entry<Integer, Cam16> entry : colorsToCam.entrySet()) { |
| int color = entry.getKey(); |
| Cam16 cam = entry.getValue(); |
| |
| double proportion = colorsToExcitedProportion.get(color); |
| double proportionScore = proportion * 100.0 * WEIGHT_PROPORTION; |
| |
| double chromaWeight = |
| cam.getChroma() < TARGET_CHROMA ? WEIGHT_CHROMA_BELOW : WEIGHT_CHROMA_ABOVE; |
| double chromaScore = (cam.getChroma() - TARGET_CHROMA) * chromaWeight; |
| |
| double score = proportionScore + chromaScore; |
| colorsToScore.put(color, score); |
| } |
| |
| // Remove colors that are unsuitable, ex. very dark or unchromatic colors. |
| // Also, remove colors that are very similar in hue. |
| List<Integer> filteredColors = filter(colorsToExcitedProportion, colorsToCam); |
| Map<Integer, Double> filteredColorsToScore = new HashMap<>(); |
| for (int color : filteredColors) { |
| filteredColorsToScore.put(color, colorsToScore.get(color)); |
| } |
| |
| // Ensure the list of colors returned is sorted such that the first in the |
| // list is the most suitable, and the last is the least suitable. |
| List<Map.Entry<Integer, Double>> entryList = new ArrayList<>( |
| filteredColorsToScore.entrySet()); |
| Collections.sort(entryList, new ScoredComparator()); |
| List<Integer> colorsByScoreDescending = new ArrayList<>(); |
| for (Map.Entry<Integer, Double> entry : entryList) { |
| int color = entry.getKey(); |
| Cam16 cam = colorsToCam.get(color); |
| boolean duplicateHue = false; |
| |
| for (Integer alreadyChosenColor : colorsByScoreDescending) { |
| Cam16 alreadyChosenCam = colorsToCam.get(alreadyChosenColor); |
| if (MathUtils.differenceDegrees(cam.getHue(), alreadyChosenCam.getHue()) < 15) { |
| duplicateHue = true; |
| break; |
| } |
| } |
| |
| if (duplicateHue) { |
| continue; |
| } |
| colorsByScoreDescending.add(entry.getKey()); |
| } |
| |
| // Ensure that at least one color is returned. |
| if (colorsByScoreDescending.isEmpty()) { |
| colorsByScoreDescending.add(0xff4285F4); |
| } |
| return colorsByScoreDescending; |
| } |
| |
| private static List<Integer> filter( |
| Map<Integer, Double> colorsToExcitedProportion, Map<Integer, Cam16> colorsToCam) { |
| List<Integer> filtered = new ArrayList<>(); |
| for (Map.Entry<Integer, Cam16> entry : colorsToCam.entrySet()) { |
| int color = entry.getKey(); |
| Cam16 cam = entry.getValue(); |
| double proportion = colorsToExcitedProportion.get(color); |
| |
| if (cam.getChroma() >= CUTOFF_CHROMA |
| && ColorUtils.lstarFromArgb(color) >= CUTOFF_TONE |
| && proportion >= CUTOFF_EXCITED_PROPORTION) { |
| filtered.add(color); |
| } |
| } |
| return filtered; |
| } |
| |
| static class ScoredComparator implements Comparator<Map.Entry<Integer, Double>> { |
| ScoredComparator() { |
| } |
| |
| @Override |
| public int compare(Map.Entry<Integer, Double> entry1, Map.Entry<Integer, Double> entry2) { |
| return -entry1.getValue().compareTo(entry2.getValue()); |
| } |
| } |
| } |