JavaScript提取图片主题色

本文同时发表在我的博客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. 通过中位切分算法提取样本

如果我们拥有的颜色数量比需要的样本数量多,利用中位切割算法将颜色数量裁剪到需要的采样数量。

  1. 将所有的颜色放入一个长方体(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
        }
    }
};
  1. 将这个 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();
};
  1. 将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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,839评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,543评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,116评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,371评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,384评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,111评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,416评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,053评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,558评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,007评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,117评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,756评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,324评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,315评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,539评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,578评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,877评论 2 345

推荐阅读更多精彩内容

  • 关于父亲的记忆不多,但每次回忆起来都会落泪。 多年前的一个深夜,我睡得正熟,妈妈叫醒我说爸爸回来了。我已经好多年没...
    曹静郑州阅读 1,731评论 100 67
  • 今天有人分享到我的痛处了
    梵音瑜伽谢霆锋阅读 170评论 0 0
  • 关于医保,我有几点建议:1.不要定总额,还是按原来人头×各科室指标,但方向要变,不能象以前用小病轻病去拉,然后做空...
    言妈阅读 185评论 0 0