一. Palette介绍和用法
Palette是调色板,可以用来获取一张Bitmap的主色调,Android Studio中,直接引用androidx包就可以(建议不要用support包),代码使用方式如下:
final Bitmap paletteBitmap = bitmap;
if (paletteBitmap != null) {
Palette.from(paletteBitmap).maximumColorCount(16).generate(palette -> {
if (palette != null) {
Palette.Swatch swatch = palette.getDominantSwatch();//最多的一种
/*
Palette.Swatch s = p.getVibrantSwatch(); //获取充满活力的色调
Palette.Swatch s = p.getDarkVibrantSwatch(); //获取充满活力的黑
Palette.Swatch s = p.getLightVibrantSwatch();//获取充满活力的亮
Palette.Swatch s = p.getMutedSwatch(); //获取柔和的色调
Palette.Swatch s = p.getDarkMutedSwatch(); //获取柔和的黑
Palette.Swatch s = p.getLightMutedSwatch(); //获取柔和的亮
*/
if (swatch != null) {
int color = swatch.getRgb(); //RGB颜色
//swatch.getHsl() HSL颜色
//swatch.getPopulation() 样本数
}
...
}
很简单,传入一张Bitmap,返回你想要的主色调。其中maximumColorCount方法设置调色板中的最大颜色数,默认值为16,最佳值取决于源图像。对于风景,最佳值范围为8-16,而带有人脸的图片通常具有介于24-32之间的值。数值越大, Palette.Builder需要更长的时间来产生更多的颜色的调色板。
二. Palette算法原理
算法的原理很简单,就是统计每一种颜色出现的次数,由于颜色很多,占用的空间太大,会先进行图片的压缩,然后统计的时候,由于RGB颜色的空间很大,也会对颜色进行归类,一些相近的颜色会算成同一个颜色。
1.图片缩放
由于源图一般较大,Palette可以指定缩放的大小,通过resizeBitmapArea()或者resizeBitmapSize(),优先使用resizeArea,其默认值是112×112,resizeBitmapArea()就是设置resizeArea,缩放比例公式(w是源图宽度,h是源图高度):
scaleRatio = resizeArea /(w × h)
其次使用resizeMaxDimension,没有默认值,resizeBitmapSize()就是设置resizeMaxDimension,这个是按照宽边进行缩放,缩放比例公式:
max = Max(w, h)
scaleRatio = resizeMaxDimension / max
2.颜色统计
图片压缩后,默认112 x 112的大小,进行统计也会占用较大的空间,一张ARGB8888格式的bitmap,每个像素是32bit,其中ARGB各占8bit,不考虑A通道,RGB可以表示的颜色有2^ 8 * 2^ 8 * 2^ 8=2^ 24=16M,如果统计每一种颜色的次数,次数用int表示,将需要16M个int的空间,约64M Bytes,所以该算法先对颜色进行了压缩,其原理如上图所示,直接抹去RGB的低N位(算法取值是3),这样的话,颜色就变成了2^15=32K个,使用int统计就是128K。
对于抹去低3位的做法,将2^9=512种颜色压成1种,从RGB颜色上示意如下:
(00000000, 00000000, 00000000) ~ (00000000, 00000000, 00000001) … (00000111, 00000111 ,00000111) —> (00000, 00000, 00000)
(00000000, 00000000, 00001000) ~ (00000000, 00000000, 00001001) … (00000111, 00000111 ,00001111) —> (00000, 00000, 00001)
…
(11111000, 11111000, 11111000) ~ (11111000, 11111000, 11111001) … (11111111, 11111111 ,11111111) —> (11111, 11111, 11111)
下图是RGB的色彩空间,对应上面的计算就是每一个小块压缩成一个像素:
接下来就是对颜色进行统计,将112 x 112个像素点每一个都转成上面5x5x5后,建立一个int[]数组hist,大小是2^15,就是所有颜色,将112 x 112个像素点的颜色进行分布统计,例如,第5个点的颜色值是(00000, 00000, 00001) ,就在hist[1]++,最后hist就是颜色的统计,其中index是color值,hist[index],就是这个color出现的次数。统计的时候,如果有Filter(可通过addFilter增加),在这里可以过滤不想被统计进来的颜色,比如我不想要红色,也可以将RGB转成HSL,过滤某一类颜色,最后取出hist[color]大于0的,如果得到的颜色个数 count <= 16(个数由maximumColorCount参数确定),就直接返回,否则进行中位切割(median cut)算法来生成 16 个颜色。
HSL 和 HSV(也叫做 HSB)是对RGB 色彩空间中点的两种有关系的表示,它们尝试描述比 RGB 更准确的感知颜色联系,并仍保持在计算上简单。HSL 表示 hue(色相)、saturation(饱和度)、lightness(亮度),HSV 表示 hue、 saturation、value 而 HSB 表示 hue、saturation、brightness(明度)。在有些做比较的情况下,RGB不好理解,转成HSL可以更加简单,比如过滤亮度很小的颜色,用RGB不好实现,用HSL就可以直接比较L分量。
3.中位切割
中位切割算法(Median cut)是Paul Heckbert于1979年提出来的算法。概念上很简单,却也是最知名、应用最为广泛的减色算法(Color quantization)。常见的影像处理软件如Photoshop、GIMP…等,都使用了这个算法或其变种。
假如有任意一张图片,想要降低影像中的颜色数目到256色。
- 将图片内的所有像素加入到同一个区域
- 对于所有的区域做以下的事
- 计算此区域内所有像素的RGB三元素最大值与最小值的差
- 选出相差最大的那个颜色(R或G或B)
- 根据那个颜色去排序此区域内所有像素
- 分割前一半与后一半的像素到二个不同的区域(这里就是"中位切割"名字的由来)
- 重复第二步直到你有256个区域
- 将每个区域内的像素平均起来,于是就得到了256色
Palette 中的中位切割算法是进行了优化的,不过算法思想是不变的。首先引入一个数据结构 Vbox 来表示待切割的对象。
private class Vbox {
// lower and upper index are inclusive
private int mLowerIndex;
private int mUpperIndex;
// Population of colors within this box
private int mPopulation;
private int mMinRed, mMaxRed;
private int mMinGreen, mMaxGreen;
private int mMinBlue, mMaxBlue;
}
Vbox 只是一个包装类。其中 mLowerIndex 和 mUpperIndex 分别代表这个 Vbox 所关心的 mColors 数组中的对应的索引的上下界。mColors 就是一个普通的数组,代表我们上文统计出来的直方图的数据结构原型,数组值代表这个色值对应的像素的个数。注意 Vbox 中的mMinRed 和 mMaxRed 系列变量,他们代表的所有 Vbox 中包含的色值在 RGB 三个分量上的最大最小值,进行中位切割的时候,Vbox 会首先对色值进行一个排序。但是,排序的依据不是简单的色值大小,而是"范围最大的颜色分量"。比如,(mMaxRed - mMinRed) > (mMaxGreen - mMinGreen) > (mMaxBlue - mMinBlue),那么就是以各个色值的 R 分量的大小进行排序。然后类似于二分法,找到排序结果的中位,进行分割,得到两个新的、小的 Vbox 。每一个 Vbox,我们在生成最终结果的时候,都会将其中包含的色值取平均数。
回到采样算法中,我们最初预想得到 16 个采样值。那么就需要有至少 16个 Vbox。当有多个 Vbox 的时候,如何选择下一个待切割的 Vbox 呢?palette 类对取下一个 Vbox 做了优化。这里,还需要引入一个颜色体积的概念。我们把 (mMaxRed - mMinRed) * (mMaxBlue - mMinBlue) * (mMaxGreen - mMinGreen) 记为颜色体积,然后每次取下一个 Vbox 的时候,取颜色体积最大的那个。通过给优先队列传递的Comparator控制的下一个将要切割的Vbox。
算法开始,将Vbox入到PriorityQueue里,初始只有一个,其Comparator就是上面说的颜色体积大小。
算法步骤:
- PriorityQueue中取出第一个Vbox(优先级最高的)
- 计算Vbox中的RGB最大分量D
- 根据D将colors数组的int color值都进行转换,将最大分量放到最前面
- 如果D=R,保持RGB格式
- 如果D=G,转换成GRB格式
- 如果D=B,转换成BGR格式
- 对colors排序
- 再进行一次步骤3的转换,重新转回RGB格式
- 循环colors,累计其hist,当累计超过hist总数的一半时,记录下index
- 根据index,将Vbox分成2个,入到PriorityQueue中
- 如果PriorityQueue的元素个数不到maximumColorCount(16),继续步骤1,否则结束
步骤2~5的示意图如下:
注意,最后的队列中所有的Vbox,其index都是颜色数组的下标,PriorityQueue中将包含16个Vbox,然后每个Vbox计算一个平均颜色和点数,输出格式List,其中Swatch信息如下:
public static final class Swatch {
private final int mRed, mGreen, mBlue;
private final int mRgb;
private final int mPopulation;
private int mTitleTextColor;
private int mBodyTextColor;
@Nullable private float[] mHsl;
}
4.获取颜色
最后的步骤就是从List取最合适的值了:
getDominantSwatch: //获取点数(population)最多的Swatch
getVibrantSwatch(); //获取充满活力的色调
getDarkVibrantSwatch(); //获取充满活力的黑
getLightVibrantSwatch();//获取充满活力的亮
getMutedSwatch(); //获取柔和的色调
getDarkMutedSwatch(); //获取柔和的黑
getLightMutedSwatch(); //获取柔和的亮
除了getDominantSwatch是获取点数最多的Swatch外,其余的都和颜色值有关,getVibrantSwatch,getDarkVibrantSwatch,getLightVibrantSwatch,getMutedSwatch,getDarkMutedSwatch,getLightMutedSwatch实际上都是一种Target,Target信息如下:
public final class Target {
final float[] mSaturationTargets = new float[3];
final float[] mLightnessTargets = new float[3];
final float[] mWeights = new float[3];
...
public static final @NonNull Target LIGHT_VIBRANT;
public static final @NonNull Target VIBRANT;
public static final @NonNull Target DARK_VIBRANT;
public static final @NonNull Target LIGHT_MUTED;
public static final @NonNull Target MUTED;
public static final @NonNull Target DARK_MUTED;
}
最重要的就是这三个数组,分别是饱和度数组,分别表示min,target,max值,默认值是[0, 0.5, 1],亮度数组,分别表示min,target,max值,默认值是[0, 0.5, 1],权重数组,分别表示饱和度,亮度,点数的权重,默认值是[0.24, 0.52, 0.24],前面几种信息都是对应Target,并且会修改饱和度数组和亮度数组,一把不会修改权重数组,除非自定义Target,以getLightMutedSwatch为例,对应LIGHT_MUTED。
LIGHT_MUTED修改了饱和度数组[0, 0.3, 0.4],亮度数组[0.55, 0.74, 1],Palette类会对所有的Target,默认就是前面的6个,就行List的匹配,使得每一个Target都有分数最高的一个Swatch对应,也可能是null,打分过程如下:
private float generateScore(Swatch swatch, Target target) {
final float[] hsl = swatch.getHsl();
float saturationScore = 0;
float luminanceScore = 0;
float populationScore = 0;
final int maxPopulation = mDominantSwatch != null ?
mDominantSwatch.getPopulation() : 1;
if (target.getSaturationWeight() > 0) {
saturationScore = target.getSaturationWeight()
* (1f - Math.abs(hsl[1] - target.getTargetSaturation()));
}
if (target.getLightnessWeight() > 0) {
luminanceScore = target.getLightnessWeight()
* (1f - Math.abs(hsl[2] - target.getTargetLightness()));
}
if (target.getPopulationWeight() > 0) {
populationScore = target.getPopulationWeight()
* (swatch.getPopulation() / (float) maxPopulation);
}
return saturationScore + luminanceScore + populationScore;
}
分数最高的Swatch,就是该Target对应的Swatch。LIGHT_MUTED修改了亮度数组[0.55, 0.74, 1],可以看到light数组明显被调大,其target值是0.74,亮度分计算公式是:
luminanceScore = target.getLightnessWeight()
* (1f - Math.abs(hsl[2] - target.getTargetLightness()));
也就是说,目标Swatch的HSL的L分量越大,其和0.74的差值越小,打分就会越高,而对饱和度的数组是[0, 0.3, 0.4],目标值调小了,目标Swatch的HSL的S分量越小,其和0.3的差值越小,打分就会越高。对自定义Target就是修改刚才说的几个数组,从而影响打分过程。上面的数组中,打分过程只用了中间的target值,没有使用min和max,其实这两个是用来限制能不能参加打分的:
private boolean shouldBeScoredForTarget(final Swatch swatch,
final Target target) {
// Check whether the HSL values are within the correct ranges, and this
// color hasn't been used yet.
final float hsl[] = swatch.getHsl();
return hsl[1] >= target.getMinimumSaturation()
&& hsl[1] <= target.getMaximumSaturation()
&& hsl[2] >= target.getMinimumLightness()
&& hsl[2] <= target.getMaximumLightness()
&& !mUsedColors.get(swatch.getRgb());
}
LIGHT_MUTED修改了亮度数组[0.55, 0.74, 1],当亮度不在0.551之前,且饱和度不在00.4之间,是没有机会参与LIGHT_MUTED的打分的,这也是这个Target用来表示获取柔和的亮的原因。
三. WallpaperColors接口
WallpaperManager中有获取壁纸主色调的接口:
wallpaperManager.getWallpaperColors(WallpaperManager.FLAG_SYSTEM)
这个没有AndroidX提供的Palette接口通用,不能传入bitmap,只能获取壁纸的颜色,返回的类型是WallpaperColors,最多计算3种颜色,分别通过以下接口获取:
public @NonNull Color getPrimaryColor() {
return mMainColors.get(0);
}
public @Nullable Color getSecondaryColor() {
return mMainColors.size() < 2 ? null : mMainColors.get(1);
}
public @Nullable Color getTertiaryColor() {
return mMainColors.size() < 3 ? null : mMainColors.get(2);
}
第一主色肯定存在,后面两种是可能为空的。这里我们主要关注其实现原理,其实现也是通过Palette,但是是在System-server进程,而且其算法和AndroidX提供的Palette有差别,没有提供各种Target的打分,直接取得的population最多的3个,而且也没有颜色统计和中位切割的步骤,其直接使用的是K-means聚类算法。
1.K-means聚类算法
K-means聚类算法是一种迭代求解的聚类分析算法,其步骤是随机选取K个对象作为初始的聚类中心,然后计算每个对象与各个种子聚类中心之间的距离,把每个对象分配给距离它最近的聚类中心。聚类中心以及分配给它们的对象就代表一个聚类。每分配一个样本,聚类的聚类中心会根据聚类中现有的对象被重新计算。这个过程将不断重复直到满足某个终止条件。终止条件可以是没有(或最小数目)对象被重新分配给不同的聚类,没有(或最小数目)聚类中心再发生变化,误差平方和局部最小。如下:
- 先确定一个k值,即我们希望将数据集经过聚类得到k个集合。
- 从数据集中随机选择k个数据点作为质心。
- 对数据集中每一个点,计算其与每一个质心的距离(如欧式距离),离哪个质心近,就划分到那个质心所属的集合。
- 把所有数据归好集合后,一共有k个集合。然后重新计算每个集合的质心。
- 如果新计算出来的质心和原来的质心之间的距离小于某一个设置的阈值(表示重新计算的质心的位置变化不大,趋于稳定,或者说收敛),我们可以认为聚类已经达到期望的结果,算法终止。
- 如果新质心和原质心距离变化很大,需要迭代3~5步骤。
算法优点:
- 原理比较简单,实现也是很容易,收敛速度快。
- 当结果簇是密集的,而簇与簇之间区别明显时, 它的效果较好。
- 主要需要调参的参数仅仅是簇数k。
算法缺点:
- K值需要预先给定,很多情况下K值的估计是非常困难的。
- K-Means算法对初始选取的质心点是敏感的,不同的随机种子点得到的聚类结果完全不同 ,对结果影响很大。
- 对噪音和异常点比较的敏感。
- 采用迭代方法,可能只能得到局部的最优解,而无法得到全局的最优解。
2.WallpaperColors的Palette实现
代码可以参考/frameworks/base/core/java/android/app/WallpaperColors.java
final Palette palette = Palette
.from(bitmap)
.setQuantizer(new VariationalKMeansQuantizer())
.maximumColorCount(5)
.clearFilters()
.resizeBitmapArea(MAX_WALLPAPER_EXTRACTION_AREA)
.generate();
可以看到,maxColor只输入5,而且有一个setQuantizer的方法,指定了量化器,Palette的general会调用量化器的唯一接口quantize:
public Palette generate() {
...
mQuantizer.quantize(getPixelsFromBitmap(bitmap),
mMaxColors, mFilters.isEmpty() ? null :
mFilters.toArray(new Palette.Filter[mFilters.size()]));
...
}
quantize的实现步骤如下:
- 随机生成K个点作为中心点,这个里K=5
- 将bitmap的所有点进行means聚类,使用HSL格式(H转为0~1),使用欧式距离
- 每个簇计算新的中心点
- 判断每一个簇新的中心点和旧的中心点是否变化,如果变化,继续步骤2(最多执行30次),否则结束聚类。
- 合并距离过近的簇,距离是否过近,通过参数自定义,默认是0.25
- 合并后的簇,每一个就是一个Swatch
- 过滤点数不足的Swatch,最后按照点数从大到小排序,点数不足的条件是:
bitmap.getWidth() * bitmap.getHeight() * MIN_COLOR_OCCURRENCE
也就是必须超过bitmap总点数的5%
AndroidX的Palette和WallpaperColors提供的Palette,算法实现不太一样,这个主要的区别:
- 前者使用缩减的RGB颜色进行直接统计,统计后使用中位切割进行颜色分类,后者使用聚簇算法对HSL格式的颜色进行分类。
- 前者分类后,可以灵活的通过参数选取不同种类的颜色,后者只能返回点数最多的。
- 前者可以传入Bitmap,后者只能处理当前壁纸,但是后者有一个好处,其内存和时间都是在system-server进程。