氛圍燈並不支持所有的顏色,只能支持256色,所以在取到圖片顏色後需要根據結果顏色去跟氛圍燈所支持的256色對比,取最接近的結果色,然後同步到氛圍燈顯示
取色流程
取色需要用到原生 Palette.from(bitmap).generate() 方法,通過量化算法分析位圖的像素顏色分佈,提取最具代表性的顏色組合,也有異步獲取方法,下面方法都處於子線程,所以這裏直接使用同步方法
查看 androidx.palette.graphics.Palette 源碼可以得知,該方法默認提取16種顏色樣本

需要確保取色精準度,16可能錯過次要但視覺顯著的顏色,過高又會導致耗時,所以這裏使用24
針對原圖還需要縮放處理,但是不宜過度,否則對準確度會有影響,這裏對2560分辨率的圖片縮小三分之一處理
private val mWidth = ScreenUtils.getScreenWidth() / 2 private val mHeight = ScreenUtils.getScreenHeight() / 2 Glide.with(Utils.getApp()) .asBitmap() .load(new File(path)) .override(width, height) .centerCrop() .skipMemoryCache(true) .diskCacheStrategy(DiskCacheStrategy.NONE) .submit(width, height) .get();
對氛圍燈的256色進行緩存處理,先新建 color_rgb_256.json 文件,將rgb色值保存,用於後續轉換對比

初始化時解析成hsv緩存到本地集合中
private fun saveHsvColor(): MutableList<HsvColor> { log("saveHsvColor") val hsvList = mutableListOf<HsvColor>() runCatching { val assetManager = Utils.getApp().assets val file = assetManager.open("color_rgb_256.json") val jsonStr = file.bufferedReader().readText() file.close() val bean = Gson().fromJson(jsonStr, AmbientLightList::class.java) val hsvColors = FloatArray(3) for (i in 0 until bean.list.size) { bean.list[i].apply { val myColor = Color.rgb(r, g, b) Color.colorToHSV(myColor, hsvColors) hsvList.add(HsvColor(hsvColors[0], hsvColors[1], hsvColors[2])) } } val json = Gson().toJson(hsvList) log("saveHsvColor hsvListSize=${hsvList.size}") SharedPreferencesUtils.setRGB256HsvColor(Utils.getApp(), json) }.getOrElse { Log.e(TAG, "saveHsvColor Exception ${it.message}") } return hsvList }
此文件顏色不會變,所以不用重複操作,判斷首次轉換就行
private fun initHsvColor() { if (hsvTableList.isEmpty()) { runCatching { val json = SharedPreferencesUtils.getRGB256HsvColor(Utils.getApp()) val listType = object : TypeToken<MutableList<HsvColor>>() {}.type Gson().fromJson<MutableList<HsvColor>>(json, listType)?.let { hsvTableList.addAll(it) log("initHsvColor xml list size=${hsvTableList.size}") } }.getOrElse { Log.e(TAG, "initHsvColor Exception ${it.message}") } } if (hsvTableList.isEmpty()) { saveHsvColor().let { if (it.isNotEmpty()) { hsvTableList.addAll(it) } } log("initHsvColor json list size=${hsvTableList.size}") } }
耗時操作需要放在子線程
@JvmStatic fun init() { log("$TAG init") scope.launch(Dispatchers.IO) { hsvTableList.clear() initHsvColor() } }
後面對圖片進行取色,見下面方案
取色後,跟256色進行就近查找,所以需要轉換成hsv,取 hue 進行對比
private fun findColor(bgHue: Float): ColorTipBean { if (hsvTableList.isEmpty()) { Log.w(TAG, "findColor hsvList is null") return ColorTipBean(Color.WHITE) } var result = hsvTableList[0] var minDiff = abs(result.hue - bgHue) for (i in 0 until hsvTableList.size) { val currentDiff = abs(hsvTableList[i].hue - bgHue) if (currentDiff < minDiff) { minDiff = currentDiff result = hsvTableList[i] } } log("findColor bgHue=$bgHue,result=$result") return ColorTipBean( Color.HSVToColor(floatArrayOf(result.hue, result.saturation, result.value)) ) }
拿到結果後,通過信號下設到氛圍燈顯示
準確度
想要達到聯動效果,需要確保取色結果的準確度,原生方案使用 getDominantColor 直接獲取主色,但是大部分結果差異較大,下面提供了幾種方案對比
方案一:
通過原生提供的方法直接獲取圖片主色
Palette.from(newMap).generate().apply { val dominantColor = getDominantColor(Color.WHITE) val hsvColorArray = FloatArray(3) val hsv = colorToHSV(dominantColor, hsvColorArray) Log.d(TAG, "dominantColor $dominantColor hsv $hsv") result.fill(hsv) }
getDominantColor 方法直接取的 mDominantSwatch.getRgb
/** * Returns the color of the dominant swatch from the palette, as an RGB packed int. * * @param defaultColor value to return if the swatch isn't available * @see #getDominantSwatch() */ @ColorInt public int getDominantColor(@ColorInt int defaultColor) { return mDominantSwatch != null ? mDominantSwatch.getRgb() : defaultColor; }
而 mDominantSwatch 則根據色塊 population 排序的結果
Palette(List<Swatch> swatches, List<Target> targets) { mSwatches = swatches; mTargets = targets; mUsedColors = new SparseBooleanArray(); mSelectedSwatches = new ArrayMap<>(); mDominantSwatch = findDominantSwatch(); } @Nullable private Swatch findDominantSwatch() { int maxPop = Integer.MIN_VALUE; Swatch maxSwatch = null; for (int i = 0, count = mSwatches.size(); i < count; i++) { Swatch swatch = mSwatches.get(i); if (swatch.getPopulation() > maxPop) { maxSwatch = swatch; maxPop = swatch.getPopulation(); } } return maxSwatch; }
假設氛圍燈需要多個取色,可以直接從 mSwatches 顏色集合中按 population 排序獲取

Swatch 代表的顏色在圖片中的權重佔比(多個小紅點可能被聚類到同一個紅色 Swatch)
經自測驗證,改方案准確度不夠,偏差較大,特別是在氛圍燈所支持的256色中,查找出的相近結果出入較大,整體準確度不夠
因為實際環境中無法看到氛圍燈(車機上效果),所以在左上角顯示測試結果,方便查看

圖片中,左上角測試區域,中間上面是圖片主色,下面是通過主色映射的氛圍燈顏色,很顯然跟圖片差異較大
方案二:
在原生基礎上使用飽和度跟亮度參與計算,避免過暗或過亮的顏色
fun getPerceptuallyDominantColor(bitmap: Bitmap): Int { val palette = Palette.from(bitmap).maximumColorCount(24).clearFilters().generate() val swatches = palette.swatches if (swatches.isEmpty()) return Color.WHITE var bestSwatch: Swatch? = null var maxScore = 0f for (swatch in swatches) { val hsl = swatch.getHsl() val saturation = hsl[1] // 飽和度 (0-1) val luminance = hsl[2] // 亮度 (0-1) val population = swatch.population // 評分公式:人口占比 * 飽和度 * 亮度因子 // 亮度因子確保避免過暗或過亮的顏色(0.1-0.9為理想範圍) val luminanceFactor = 1f - abs(luminance - 0.5f) * 1.8f val score = population * saturation * luminanceFactor if (score > maxScore) { maxScore = score bestSwatch = swatch } } return bestSwatch?.rgb ?: palette.getDominantColor(Color.WHITE) }
該方案將純黑白色過濾(實際圖片中純黑白色佔比很少,但是很印象色塊,容易出現誤差),同時避免了過亮的顏色,更突出我們肉眼看到的顏色
其它方案:
1、在方案二的基礎上,加入色相,改進計算公式
2、調整圖片,縮小區域,針對中心區域進行取色
3、自定義過濾器,針對業務情況單獨處理某些圖片
比如,可以針對純黑白佔比大於30%的進行過濾,否則不過濾
private fun isClear(bitmap: Bitmap): Boolean { val totalPixels = bitmap.width * bitmap.height var blackCount = 0.0 var whiteCount = 0.0 for (x in 0 until bitmap.width) { for (y in 0 until bitmap.height) { val pixel = bitmap[x, y] if (pixel == Color.BLACK) { blackCount++ } if (pixel == Color.WHITE) { whiteCount++ } } } val blackRatio = blackCount / totalPixels val whiteRatio = whiteCount / totalPixels val isClear = blackRatio > 0.3 || whiteRatio > 0.3 Log.d(TAG, "isClear=$isClear totalPixels=$totalPixels,blackCount=$blackCount, blackRatio=${String.format("%.2f", blackRatio)},whiteRatio=${String.format("%.2f", whiteRatio)}") return isClear }
但需要慎重,會提高計算耗時



左上角,上面的方格代表直接從圖片中讀取的色值,下面的方格是映射後的色值,最左邊的是方案二,中間的是方案一,右邊的是替補方案
結論圖片不多展示,經過大量圖片驗證,準確度最高的是方案二
import android.graphics.Bitmap import android.graphics.Color import android.util.Log import androidx.palette.graphics.Palette import androidx.palette.graphics.Palette.Swatch import com.blankj.utilcode.util.GsonUtils import com.blankj.utilcode.util.ScreenUtils import com.blankj.utilcode.util.Utils import com.google.gson.Gson import com.google.gson.annotations.SerializedName import com.google.gson.reflect.TypeToken import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.MainScope import kotlinx.coroutines.launch import java.util.Collections import java.util.concurrent.CopyOnWriteArrayList import kotlin.math.abs import kotlin.math.sqrt import androidx.core.graphics.get object AmbientLightColorPickManager { private const val TAG = "AmbientLightColorPickManager" private var scope = MainScope() private val mWidth = ScreenUtils.getScreenWidth() / 2 private val mHeight = ScreenUtils.getScreenHeight() / 2 private val hsvTableList = mutableListOf<HsvColor>() private val hueList = CopyOnWriteArrayList<FloatArray>() private val test1List = CopyOnWriteArrayList<FloatArray>() private val test2List = CopyOnWriteArrayList<FloatArray>() private val test3List = CopyOnWriteArrayList<FloatArray>() var test1Listener: ((Int, Int, Int) -> Unit)? = null var test2Listener: ((Int, Int, Int) -> Unit)? = null @JvmStatic fun init() { log("$TAG init") scope.launch(Dispatchers.IO) { hsvTableList.clear() initHsvColor() } } private fun initHsvColor() { if (hsvTableList.isEmpty()) { runCatching { val json = SharedPreferencesUtils.getRGB256HsvColor(Utils.getApp()) val listType = object : TypeToken<MutableList<HsvColor>>() {}.type Gson().fromJson<MutableList<HsvColor>>(json, listType)?.let { hsvTableList.addAll(it) log("initHsvColor xml list size=${hsvTableList.size}") } }.getOrElse { Log.e(TAG, "initHsvColor Exception ${it.message}") } } if (hsvTableList.isEmpty()) { saveHsvColor().let { if (it.isNotEmpty()) { hsvTableList.addAll(it) } } log("initHsvColor json list size=${hsvTableList.size}") } } /** 將本地rgb色值轉換成hsv保存到本地 */ private fun saveHsvColor(): MutableList<HsvColor> { log("saveHsvColor") val hsvList = mutableListOf<HsvColor>() runCatching { val assetManager = Utils.getApp().assets val file = assetManager.open("color_rgb_256.json") val jsonStr = file.bufferedReader().readText() file.close() val bean = Gson().fromJson(jsonStr, AmbientLightList::class.java) val hsvColors = FloatArray(3) for (i in 0 until bean.list.size) { bean.list[i].apply { val myColor = Color.rgb(r, g, b) Color.colorToHSV(myColor, hsvColors) hsvList.add(HsvColor(hsvColors[0], hsvColors[1], hsvColors[2])) } } val json = Gson().toJson(hsvList) log("saveHsvColor hsvListSize=${hsvList.size}") SharedPreferencesUtils.setRGB256HsvColor(Utils.getApp(), json) }.getOrElse { Log.e(TAG, "saveHsvColor Exception ${it.message}") } return hsvList } /** 設置氛圍燈 */ @JvmStatic fun setAmbientLight(displayId: Int, index: Int) { if (displayId != DisplayParameter.DISPLAY_CSD.displayId) return log("setAmbientLight displayId=$displayId") scope.launch(Dispatchers.IO) { if (hueList.isEmpty()) { Log.w(TAG, "setAmbientLight hueList is null") return@launch } if (index < 0 || index >= hueList.size) { Log.w(TAG, "setAmbientLight 索引異常") return@launch } // 氛圍燈取色 setBytesFunctionValue(index) } } @JvmStatic fun switchLight(isOn: Boolean) { log("switchLight isOn=$isOn") } private fun findColor(bgHue: Float): ColorTipBean { if (hsvTableList.isEmpty()) { Log.w(TAG, "findColor hsvList is null") return ColorTipBean(Color.WHITE) } var result = hsvTableList[0] var minDiff = abs(result.hue - bgHue) for (i in 0 until hsvTableList.size) { val currentDiff = abs(hsvTableList[i].hue - bgHue) if (currentDiff < minDiff) { minDiff = currentDiff result = hsvTableList[i] } } log("findColor bgHue=$bgHue,result=$result") return ColorTipBean( Color.HSVToColor(floatArrayOf(result.hue, result.saturation, result.value)) ) } /** 初始化資源 */ @JvmStatic fun loadData(displayId: Int, pictures: List<String>) { if (displayId != DisplayParameter.DISPLAY_CSD.displayId) return log("loadData pictures size=${pictures.size} pictures $pictures") hueList.clear() test1List.clear() test2List.clear() test3List.clear() for ((index, picture) in pictures.withIndex()) { runCatching { val bitmap = GlideCacheUtils.loadImageAsBitmap(picture, mWidth, mHeight) testGenerate(bitmap) val result = generate(bitmap) hueList.add(result) log("loadData add index=$index,colors=${GsonUtils.toJson(result)}") }.getOrElse { Log.e(TAG, "loadData exception ${it.message}") } } log("loadData hueList size=${hueList.size}") } private fun setFunctionValue(functionId: Int, value: Int, zone: Int) { try { AdapterCarManager.iCarFunction.setFunctionValue(functionId, zone, value) } catch (e: Exception) { Log.e(TAG, "setFunctionValue Exception $e") } } private fun setBytesFunctionValue(index: Int) { try { test1Listener?.invoke( Color.HSVToColor(test1List[index]), Color.HSVToColor(test2List[index]), Color.HSVToColor(test3List[index]), ) test2Listener?.invoke( findColor(test1List[index][0]).colorTip, findColor(test2List[index][0]).colorTip, findColor(test3List[index][0]).colorTip, ) } catch (e: Exception) { Log.e(TAG, "setBytesFunctionValue Exception $e") } } private fun getColors(list: FloatArray): ByteArray { val result = mutableListOf<ColorTipBean>() list.forEach { result.add(findColor(it)) } val json = GsonUtils.toJson(LightColorBean(result).list) log("setBytesFunctionValue json=$json") return json.toByteArray() } private fun generate(newMap: Bitmap): FloatArray { val result = FloatArray(3) Log.w(TAG, "------generate start") val dominantColor = getPerceptuallyDominantColor(newMap) val hsvColorArray = FloatArray(3) val hsv = colorToHSV(dominantColor, hsvColorArray) result.fill(hsv) Log.d(TAG, "dominantColor $dominantColor, hsv ${GsonUtils.toJson(hsvColorArray)}") return result } private fun testGenerate(newMap: Bitmap) { // 評分公式 val dominantColor1 = getPerceptuallyDominantColor(newMap) val hsvColorArray1 = FloatArray(3) colorToHSV(dominantColor1, hsvColorArray1) test1List.add(hsvColorArray1) // 主色 Palette.from(newMap).maximumColorCount(24).clearFilters().generate().apply { val hsvColorArray2 = FloatArray(3) val dominantColor2 = getDominantColor(Color.WHITE) colorToHSV(dominantColor2, hsvColorArray2) test2List.add(hsvColorArray2) } // 評分優化公式 val dominantColor3 = getPerceptuallyDominantColor1(newMap) val hsvColorArray3 = FloatArray(3) colorToHSV(dominantColor3, hsvColorArray3) test3List.add(hsvColorArray3) } fun getPerceptuallyDominantColor(bitmap: Bitmap): Int { val palette = Palette.from(bitmap).maximumColorCount(24).clearFilters().generate() val swatches = palette.swatches if (swatches.isEmpty()) return Color.WHITE var bestSwatch: Swatch? = null var maxScore = 0f for (swatch in swatches) { val hsl = swatch.getHsl() val saturation = hsl[1] // 飽和度 (0-1) val luminance = hsl[2] // 亮度 (0-1) val population = swatch.population // 評分公式:人口占比 * 飽和度 * 亮度因子 // 亮度因子確保避免過暗或過亮的顏色(0.1-0.9為理想範圍) val luminanceFactor = 1f - abs(luminance - 0.5f) * 1.8f val score = population * saturation * luminanceFactor if (score > maxScore) { maxScore = score bestSwatch = swatch } } return bestSwatch?.rgb ?: palette.getDominantColor(Color.WHITE) } private fun isClear(bitmap: Bitmap): Boolean { val totalPixels = bitmap.width * bitmap.height var blackCount = 0.0 var whiteCount = 0.0 for (x in 0 until bitmap.width) { for (y in 0 until bitmap.height) { val pixel = bitmap[x, y] if (pixel == Color.BLACK) { blackCount++ } if (pixel == Color.WHITE) { whiteCount++ } } } val blackRatio = blackCount / totalPixels val whiteRatio = whiteCount / totalPixels val isClear = blackRatio > 0.3 || whiteRatio > 0.3 Log.d(TAG, "isClear=$isClear totalPixels=$totalPixels,blackCount=$blackCount, blackRatio=${String.format("%.2f", blackRatio)},whiteRatio=${String.format("%.2f", whiteRatio)}") return isClear } private fun calculateSwatchScore( hue: Float, saturation: Float, luminance: Float, population: Float ): Float { // 1. 人口權重 (標準化) val populationWeight = population / 1000000f // 2. 飽和度權重 - 適度重視但不過度 val saturationWeight = sqrt(saturation) // 使用平方根降低過高飽和度的優勢 // 3. 亮度權重 - 偏好中等亮度範圍 val luminanceWeight = when { luminance < 0.15f -> 0.2f // 太暗的懲罰 luminance > 0.85f -> 0.3f // 太亮的懲罰 else -> 1.0f - abs(luminance - 0.5f) * 1.5f } // 4. 色相權重 - 可選:降低過於鮮豔的紅色/藍色的優勢 val hueWeight = when { // 紅色範圍 (330-30度) (hue >= 330f || hue <= 30f) -> 0.8f // 藍色範圍 (210-270度) hue in 210f..270f -> 0.9f else -> 1.0f } return populationWeight * saturationWeight * luminanceWeight * hueWeight } fun getPerceptuallyDominantColor1(bitmap: Bitmap): Int { val palette = Palette.from(bitmap) .maximumColorCount(24) .clearFilters() .generate() val swatches = palette.swatches if (swatches.isEmpty()) return Color.WHITE var bestSwatch: Swatch? = null var maxScore = 0f for (swatch in swatches) { val hsl = swatch.hsl val hue = hsl[0] // 色相 (0-360) val saturation = hsl[1] // 飽和度 (0-1) val luminance = hsl[2] // 亮度 (0-1) val population = swatch.population.toFloat() // 改進的評分公式 val score = calculateSwatchScore(hue, saturation, luminance, population) if (score > maxScore) { maxScore = score bestSwatch = swatch } } return bestSwatch?.rgb ?: palette.getDominantColor(Color.WHITE) } private fun colorToHSV(rgb: Int, hsvColorArray: FloatArray): Float { Color.colorToHSV(rgb, hsvColorArray) return hsvColorArray[0] } private fun log(str: String) = Log.d(TAG, str) data class LightColorBean( val list: List<ColorTipBean> ) data class ColorTipBean( @SerializedName("ColorTip") var colorTip: Int, ) }