1
头图

前言

去年就产生一个想法,通过提取图片主题色,配合图片起到沉浸式的视觉效果,产生一种和谐、一致的感觉。磨磨蹭蹭到今年才着手开发~

花了一周的时间分别以客户端和服务端两种方式实现了这个功能,两种方式各有利弊,通常主题色的提取都是在服务端完成的,客户端将需要处理的图片以链接或id的形式提供给服务端,服务端通过运行相应的算法来提取出主题色后,再返回相应的结果。这样可以满足大多数展示类的场景,但对于需要根据用户“定制”、“生成”的图片,这样的方式就有了一个上传图片---->服务端计算---->返回结果的时间,等待时间也许会比较长。而在客户端实现,移动端浏览器的兼容问题是个很痛疼的事。

上面讲的有些累赘了,简单的说,在大多数情况下都建议选择服务端实现,在用户实时定制、生成图片情况下的提取颜色需求并且不考虑移动端的情况下可以选择客户端进行实现。

本篇和大家分享客户端的实现方案:

目前提取图片颜色比较常用的主题色提取算法有:最小差值法、中位切分法、八叉树算法、聚类、色彩建模法等,在这里我选择了中位切分法进行实现。

思路

中位切分法通常是在图像处理中降低图像位元深度的算法,可用来将高位的图转换位低位的图,如将24bit的图转换为8bit的图。我们也可以用来提取图片的主题色,其原理是是将图像每个像素颜色看作是以R、G、B为坐标轴的一个三维空间中的点,由于三个颜色的取值范围为0~255,所以图像中的颜色都分布在这个颜色立方体内,如下图所示:

从初始整个图像作为一个长方体开始,之后将RGB中最长的一边从颜色统计的中位数一切为二,使得到的两个长方体所包含的像素数量相同,如下图所示:

重复上述步骤,直到最终切分得到长方体的数量等于主题颜色数量为止,最后取每个长方体的中点即可。

在实际使用中如果只是按照中点进行切割,会出现有些长方体的体积很大但是像素数量很少的情况。解决的办法是在切割前对长方体进行优先级排序,排序的系数为体积 * 像素数。这样就可以基本解决此类问题了。

效果

代码

  1. 首先创建一个canvas容器
  2. 将图片绘制到容器中
  3. 使用getImageData方法获取rgba, 查看getImageData
  4. 通过中位数切分算法切割并提取颜色
  5. 筛选掉相似的颜色

color.vue (下列代码为VUE3.0语法)

<template>
    <div>
      <canvas style="display: none" id="canvas"></canvas>
      <div
         id="extract-color-id"
         class="extract-color"
         style="display: flex;padding: 0 20px; justify-content:end;">
      </div>
    </div>
</template>
<script lang="ts">
import themeColor from '../../components/colorExtraction';
export default defineComponent({
 setup(props) {
    /**
     * 设置颜色方法
     */
    const SetColor = (colorArr: number[][]) => {
      // 初始化删除多余子节点
      const extractColor = document.querySelector('#extract-color-id') as HTMLElement;
      while (extractColor.firstChild) {
        extractColor.removeChild(extractColor.firstChild);
      }
      // 创建子节点
      for (let index = 0; index < colorArr.length; index++) {
        const bgc = '(' + colorArr[index][0] + ',' + colorArr[index][1] + ',' + colorArr[index][2] + ')';
        const colorBlock = document.createElement('div') as HTMLElement;
        colorBlock.id = `color-block-id${index}`;
        colorBlock.style.cssText = 'height: 50px;width: 50px;margin-right: 10px;border-radius: 50%;';
        colorBlock.style.backgroundColor = `rgb${bgc}`;
        extractColor.appendChild(colorBlock);
      }
    };
    
    onMounted(()=> {
        const img = new Image();
        img.src = `图片的地址`;
        img.crossOrigin = 'anonymous';
        img.onload = () => {
          themeColor(50, img, 20, SetColor);
        };
    })

colorExtraction.ts(下列代码为TypeScript语法,转换JavaScript删除掉所有的类型定义即可)

/**
 * 颜色盒子类
 *
 * @param {Array} colorRange    [[rMin, rMax],[gMin, gMax], [bMin, bMax]] 颜色范围
 * @param {any} total   像素总数, imageData / 4
 * @param {any} data    像素数据集合
 */
class ColorBox {
    colorRange: unknown[];
    total: number;
    data: Uint8ClampedArray;
    volume: number;
    rank: number;
    constructor(colorRange: any[], total: number, data: Uint8ClampedArray) {
        this.colorRange = colorRange;
        this.total = total;
        this.data = data;
        this.volume = (colorRange[0][1] - colorRange[0][0]) * (colorRange[1][1] - colorRange[1][0]) * (colorRange[2][1] - colorRange[2][0]);
        this.rank = total * this.volume;
    }
    getColor() {
        const total = this.total;
        const data = this.data;
        let redCount = 0,
            greenCount = 0,
            blueCount = 0;

        for (let i = 0; i < total; i++) {
            redCount += data[i * 4];
            greenCount += data[i * 4 + 1];
            blueCount += data[i * 4 + 2];
        }
        return [redCount / total, greenCount / total, blueCount / total];
    }
}

// 获取切割边
const getCutSide = (colorRange: number[][]) => {   // r:0,g:1,b:2
    const arr = [];
    for (let i = 0; i < 3; i++) {
        arr.push(colorRange[i][1] - colorRange[i][0]);
    }
    return arr.indexOf(Math.max(arr[0], arr[1], arr[2]));
}

// 切割颜色范围
const cutRange = (colorRange: number[][], colorSide: number, cutValue: any) => {
    const arr1: number[][] = [];
    const arr2: number[][] = [];
    colorRange.forEach(function (item) {
        arr1.push(item.slice());
        arr2.push(item.slice());
    })
    arr1[colorSide][1] = cutValue;
    arr2[colorSide][0] = cutValue;

    return [arr1, arr2];
}

// 找到出现次数为中位数的颜色
const __quickSort = (arr: any[]): any => {
    if (arr.length <= 1) {
        return arr;
    }
    const pivotIndex = Math.floor(arr.length / 2);
    const pivot = arr.splice(pivotIndex, 1)[0];
    const left = [];
    const right = [];
    for (let i = 0; i < arr.length; i++) {
        if (arr[i].count <= pivot.count) {
            left.push(arr[i]);
        }
        else {
            right.push(arr[i]);
        }
    }
    return __quickSort(left).concat([pivot], __quickSort(right));
}

const getMedianColor = (colorCountMap: Record<string, number>, total: number) => {

    const arr = [];
    for (const key in colorCountMap) {
        arr.push({
            color: parseInt(key),
            count: colorCountMap[key]
        })
    }

    const sortArr = __quickSort(arr);
    let medianCount = 0;
    const medianIndex = Math.floor(sortArr.length / 2)

    for (let i = 0; i <= medianIndex; i++) {
        medianCount += sortArr[i].count;
    }

    return {
        color: parseInt(sortArr[medianIndex].color),
        count: medianCount
    }
}

// 切割颜色盒子
const cutBox = (colorBox: { colorRange: number[][]; total: number; data: Uint8ClampedArray }) => {

    const colorRange = colorBox.colorRange;
    const cutSide = getCutSide(colorRange);
    const colorCountMap: Record<string, number> = {};
    const total = colorBox.total;
    const data = colorBox.data;

    // 统计出各个值的数量
    for (let i = 0; i < total; i++) {
        const color = data[i * 4 + cutSide];

        if (colorCountMap[color]) {
            colorCountMap[color] += 1;
        }
        else {
            colorCountMap[color] = 1;
        }
    }

    const medianColor = getMedianColor(colorCountMap, total);
    const cutValue = medianColor.color;
    const cutCount = medianColor.count;
    const newRange = cutRange(colorRange, cutSide, cutValue);
    const box1 = new ColorBox(newRange[0], cutCount, data.slice(0, cutCount * 4));
    const box2 = new ColorBox(newRange[1], total - cutCount, data.slice(cutCount * 4));
    return [box1, box2];
}

// 队列切割
const queueCut = (queue: any[], num: number) => {
    while (queue.length < num) {
        queue.sort((a: { rank: number }, b: { rank: number }) => {
            return a.rank - b.rank
        });
        const colorBox = queue.pop();
        const result = cutBox(colorBox);
        queue = queue.concat(result);
    }
    return queue.slice(0, num)
}

// 颜色去重
const colorFilter = (colorArr: number[][], difference: number) => {
    for (let i = 0; i < colorArr.length; i++) {
        for (let j = i + 1; j < colorArr.length; j++) {
            if (Math.abs(colorArr[i][0] - colorArr[j][0]) < difference && Math.abs(colorArr[i][1] - colorArr[j][1]) < difference && Math.abs(colorArr[i][2] - colorArr[j][2]) < difference) {
                colorArr.splice(j, 1)
                j--
            }
        }
    }
    return colorArr
}

/**
 * 提取颜色
 * @param colorNumber 提取最大颜色数量
 * @param img 需要提取的图片
 * @param difference 图片颜色筛选精准度
 * @param callback 回调函数
 */
const themeColor = (colorNumber: number, img: CanvasImageSource, difference: number, callback: (arg0: number[][]) => void) => {
    const canvas = document.createElement('canvas') as HTMLCanvasElement;
    const ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
    let width = 0
    let height = 0
    let imageData = null

    canvas.width = img.width as number;
    width = canvas.width as number
    canvas.height = img.height as number
    height = canvas.height

    ctx.drawImage(img, 0, 0, width, height);

    imageData = ctx.getImageData(0, 0, width, height).data;

    const total = imageData.length / 4;

    let rMin = 255,
        rMax = 0,
        gMin = 255,
        gMax = 0,
        bMin = 255,
        bMax = 0;

    // 获取范围
    for (let i = 0; i < total; i++) {
        const red = imageData[i * 4];
        const green = imageData[i * 4 + 1];
        const blue = imageData[i * 4 + 2];

        if (red < rMin) {
            rMin = red;
        }

        if (red > rMax) {
            rMax = red;
        }

        if (green < gMin) {
            gMin = green;
        }

        if (green > gMax) {
            gMax = green;
        }

        if (blue < bMin) {
            bMin = blue;
        }

        if (blue > bMax) {
            bMax = blue;
        }
    }

    const colorRange = [[rMin, rMax], [gMin, gMax], [bMin, bMax]];
    const colorBox = new ColorBox(colorRange, total, imageData);
    const colorBoxArr = queueCut([colorBox], colorNumber);
    let colorArr = [];

    for (let j = 0; j < colorBoxArr.length; j++) {
        colorBoxArr[j].total && colorArr.push(colorBoxArr[j].getColor())
    }

    colorArr = colorFilter(colorArr, difference)

    callback(colorArr);
}

export default themeColor

参考:
https://github.com/lokesh/col...
http://blog.rainy.im/2015/11/...
https://www.yuque.com/along-n...
https://cloud.tencent.com/dev...
https://xcoder.in/2014/09/17/...


lostElk
10 声望0 粉丝

迷失中悠然自得