本文同时发表在我的博客 wangyi.blog
Android Palette Library 是一个从 Bitmap 中 提取图像的主题颜色的工具库. 我最近对 Palette 的实现感兴趣, 阅读源码理解了它的原理后, 我打算用 JavaScript 来实现同样的功能.
example
1. 获取图片的像素数据
通过 canvas 获取图片的像素信息 ImageData, ImageData 中包含图片的宽高和一个 Uint8 数组, 该数组以 RGBA 的形式存储像素数据.
- let width = this.image.width;
- let height = this.image.height;
- let canvas = document.createElement('canvas');
- canvas.width = width;
- canvas.height = height;
- let ctx = canvas.getContext("2d");
- ctx.drawImage(this.image, 0, 0);
- let data = ctx.getImageData(0, 0, width, height).data;
2. 以柱状图的形式统计所有颜色出现的次数
柱状图是一个一维 int 数组, 数组 index 对应颜色的 int 值, 对应的取值表示该颜色的出现次数. RGB888 包含的颜色大约有 1600 万 (255x255x255) 种颜色, 这里将 RGB888 颜色空间转成 RGB555 颜色空间. RGB555 包含 32768(32x32x32)种颜色, 可减少大量的计算量.
- let colorCount = 1 <<15;
- let histogram = new Int16Array(colorCount);
- for (let i = 0; i < data.length; i += 4) {
- let r = data[i]>> 3;
- let g = data[i + 1]>> 3;
- let b = data[i + 2]>> 3;
- histogram[r <<(10) | g << 5 | b]++;
- }
3. 筛选出现次数大于 0 的颜色
将出现次数大于 0 的颜色保存在一个数组中, 统计不同颜色的数量 distinctColorCount.**shouldIgnoreColor ** 方法会忽略掉接近白色, 黑色和红色的颜色.
- let distinctColorCount = 0;
- for (let color = 0; color < colorCount; color++) {
- if (histogram[color]> 0 && ColorCutQuantizer.shouldIgnoreColor(color)) {
- histogram[color] = 0;
- }
- if (histogram[color]> 0) {
- distinctColorCount++
- }
- }
- let colors = new Int16Array(distinctColorCount);
- let index = 0;
- for (let color = 0; color <colorCount; color++) {
- if (histogram[color]> 0) {
- colors[index++] = color;
- }
- }
如果 distinctColorCount 小于等于我们需要提取的采样个数 maxColors, 那么我们的采样流程结束, 直接生成颜色样本.
- if (distinctColorCount <= maxColors) {
- this.quantizedColors = new Array(distinctColorCount);
- for (let i = 0; i <distinctColorCount; i++) {
- let color = colors[i];
- let r = (color>> 10) & 0x1f;
- let g = (color>> 10) & 0x1f;
- let b = color & 0x1f;
- this.quantizedColors[i] = new Swatch(r, g, b, histogram[color])
- }
- } else {
- this.quantizedColors = ColorCutQuantizer.quantizePixels(histogram, colors, maxColors)
- }
4. 通过中位切分算法提取样本
如果我们拥有的颜色数量比需要的样本数量多, 利用中位切割算法将颜色数量裁剪到需要的采样数量.
将所有的颜色放入一个长方体(Vbox)
Vbox
我们对 Vbox 进行初始化, 得到该 Vbox 对应的 R,G,B 的最大和最小值, 以及表示的该颜色范围内所有像素的数量的 population.
- fitBox() {
- this.minRed = this.minGreen = this.minBlue = Number.MAX_VALUE;
- this.maxRed = this.maxGreen = this.maxBlue = 0;
- this.population = 0;
- for (let i = this.lowerIndex; i <= this.upperIndex; i++) {
- let color = this.colors[i];
- this.population += this.histogram[color];
- let r = quantizedRed(color);
- let g = quantizedGreen(color);
- let b = quantizedBlue(color);
- if (r> this.maxRed) {
- this.maxRed = r
- }
- if (r <this.minRed) {
- this.minRed = r
- }
- if (g> this.maxGreen) {
- this.maxGreen = g
- }
- if (g <this.minGreen) {
- this.minGreen = g
- }
- if (b> this.maxBlue) {
- this.maxBlue = b
- }
- if (b <this.minBlue) {
- this.minBlue = b
- }
- }
- };
将这个 Vbox 放入一个优先级队列 (PriorityQueue) 中. JavaScript 中没有 PriorityQueue 这样的数据结构, 我在 GitHub 上找到了对应的简单实现 TinyQueue. 该队列根据 Vbox 的体积排序:
- // 获取 Vbox 的体积 - 三边长的乘积
- getVolume() {
- return (this.maxRed - this.minRed + 1) * (this.maxGreen - this.minGreen + 1) * (this.maxBlue - this.minBlue + 1);
- };
- ...
- let queue = new TinyQueue();
- queue.compare = function (a, b) {
- return b.getVolume() - a.getVolume();
- };
将 RGB 中最长的一边从颜色统计的中位数一切为二, 使得到的两个长方体所包含的像素数量相同. 中位切割最重要的是找到切割的点, 下面是我们找到 Vbox 切割点的方法:
- findSplitPoint() {
- // 获取 Vbox 最长的边
- let longestDimension = this.getLongestColorDimension();
- // 我们需要根据最长的边对该 Vbox 中的颜色进行排序, 由于当前是颜色 RGB 空间
- // 如果最长的边是 Green 则需要把颜色修改为 GRB, 如果最长边是 Blue 修改为 RGR
- Vbox.modifySignificantOctet(this.colors, longestDimension, this.lowerIndex, this.upperIndex);
- // 对 Vbox 内的颜色排序
- Vbox.sortRange(this.colors, this.lowerIndex, this.upperIndex);
- Vbox.modifySignificantOctet(this.colors, longestDimension, this.lowerIndex, this.upperIndex);
- let midPoint = this.population / 2;
- let count = 0;
- for (let i = this.lowerIndex; i <= this.upperIndex; i++) {
- count += this.histogram[this.colors[i]];
- if (count>= midPoint) {
- return Math.min(this.upperIndex - 1, i)
- }
- }
- return this.lowerIndex
- };
将分割出的 2 个的 Vbox 放入队列中, 然后我们再从队列中获取体积最大的一个 Vbox 继续分割, 直到 Vbox 数量达到我们需要的样本数量.
5. 根据 Vbox 生成样本 Swatch
getAverageColor 方法计算 Vbox 中的所有颜色的平均值, 然后生成一个 Swatch.
- getAverageColor() {
- let redSum = 0, greenSum = 0, blueSum = 0, totalPopulation = 0;
- for (let i = this.lowerIndex; i <= this.upperIndex; i++) {
- let color = this.colors[i];
- let colorPopulation = this.histogram[color];
- totalPopulation += colorPopulation;
- redSum += colorPopulation * quantizedRed(color);
- greenSum += colorPopulation * quantizedGreen(color);
- blueSum += colorPopulation * quantizedBlue(color);
- }
- let redMean = Math.round(redSum / totalPopulation);
- let greenMean = Math.round(greenSum / totalPopulation);
- let blueMean = Math.round(blueSum / totalPopulation);
- return new Swatch(redMean, greenMean, blueMean, totalPopulation);
- };
6. 根据 Target 对 Swatch 打分, 获得最终的主题颜色值列表
Target 定义了我们对颜色饱和度和亮度的最低值, 目标值和计算评分的权重要求, 默认定义了 6 种 Target:
- Vibrant (有活力的)
- Vibrant dark(有活力的 暗色)
- Vibrant light(有活力的 亮色)
- Muted (柔和的)
- Muted dark(柔和的 暗色)
- Muted light(柔和的 亮色)
我们得到的 Swatch 是 RGB 的颜色值, 需要通过转换 RGB(RGB 转 HSL 算法)得到对应的 HSL 颜色值然后打分, HSL 即色相(Hue), 饱和度(Saturation), 亮度(Lightness).
在计算分数之前需要判断该 Swatch 是否满足评分的要求 - 饱和度和亮度在 Target 的要求范围之内, 并且该 Swatch 没有被其他 Target 使用. 因此该 Targe 可能 t 获取不到对应的 Swatch.
- shouldBeScoredForTarget(swatch, target) {
- let hsl = swatch.getHsl();
- let s = hsl[1];
- let l = hsl[2];
- return s>= target.getMinimumSaturation() && s <= target.getMaximumSaturation()
- && l>= target.getMinimumLightness() && l <= target.getMaximumLightness()
- && !this.usedColors.get(swatch.rgb);
- };
我们将饱和度分数, 亮度分数, 像素 Population 分数三项分数加起来, 得到该 Target 评分最高的 Swatch.
- generateScore(swatch, target) {
- let saturationScore = 0;
- let luminanceScore = 0;
- let populationScore = 0;
- let maxPopulation = this.dominantSwatch.population;
- let hsl = swatch.getHsl();
- if (target.getSaturationWeight()> 0) {
- saturationScore = target.getSaturationWeight() * (1 - Math.abs(hsl[1] - target.getTargetSaturation()));
- }
- if (target.getLightnessWeight()> 0) {
- luminanceScore = target.getLightnessWeight() * (1 - Math.abs(hsl[2] - target.getTargetLightness()));
- }
- if (target.getPopulationWeight()> 0) {
- populationScore = target.getPopulationWeight() * (swatch.population / maxPopulation);
- }
- return saturationScore + luminanceScore + populationScore;
- };
全部代码上传到 GitHubhttps://github.com/wangyiwy/palette-js
来源: http://www.jianshu.com/p/0f57f675242d