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

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

思路

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

在这里插入图片描述

![在这里插入图片描述](https://img-blog.csdnimg.cn/fda70015efb94353a2a45bcec4fb49ce.png
从初始整个图像作为一个长方体开始,之后将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

<template>
  <div id="h5-box">
    <canvas style="display: none" id="canvas"></canvas>
    <div
      id="extract-color-id"
      style="
        display: flex;
        padding: 0 2.6667vw;
        margin: 0;
        justify-content: end;
      "
    ></div>
    <HeadSticky />
    <div class="h5-box">
      <van-card id="img">
        <template #thumb>
          <div>
            <van-image
              class="img-box"
              src="https://fastly.jsdelivr.net/npm/@vant/assets/cat.jpeg"
            />
          </div>
        </template>

        <template #title>
          <div class="title-box">
            <span class="title-text">超能一家人</span>
            <van-tag plain color="#FFFFFF" class="tag-test" :border="false"
              >直接购买</van-tag
            >
            <p class="ms-text">XXXX公司出售500份收益权</p>
          </div>
        </template>

        <template #tags>
          <div class="tag-box">
            <van-tag plain color="#F04C42">标签</van-tag>
            <van-tag plain color="#FF9223">标签</van-tag>
            <van-tag plain color="#56A5D8">标签</van-tag>
            <p class="tag-text">108分钟/电影票房分成</p>
          </div>
        </template>

        <template #price>
          <div class="price-box">
            <p class="price-child-cxt">收益权交易截止: 2022618</p>
            <p class="price-child-cxt">回收收益: 2022628</p>
          </div>
        </template>
      </van-card>

      <van-card class="brief-countent">
        <template #title>
          <div class="title-container">剧情简介:</div>
        </template>
        <template #bottom>
          <div class="brief-text">
            <TextOverFlow :content="data.info" />
          </div>
        </template>
      </van-card>

      <div class="steps-box">
        <VanSteps />
      </div>
      <div>
        <DramaView :dramaInfo="data.dramaInfo" />
      </div>
      <div style="margin-top: 10%">
        <DramaView :dramaInfo="data.previewInfo" />
      </div>
      <div style="margin-top: 10%">
        <ProjectMaterials />
      </div>
      <div class="btn-box">
        <van-button
          size="large"
          color="linear-gradient(to left, #ff6034, #ee0a24)"
          >购买</van-button
        >
      </div>
      <div class="check-box">
        <van-checkbox
          v-model="data.checked"
          checked-color="#ee0a24"
          icon-size="4vw"
        >
          <span class="check-agree">已阅读并同意</span>
          <span class="check-instructions">《收益权购买须知》</span>
        </van-checkbox>
      </div>
    </div>
  </div>
</template>

<script>
import { reactive, toRefs, onBeforeMount, onMounted } from "vue";
import HeadSticky from "./modular/HeadSticky.vue";
import TextOverFlow from "./modular/TextOverFlow.vue";
import themeColor from "./common//colorExtraction.js";
import VanSteps from "@/components/VanSteps.vue";
import DramaView from "./modular/DramaView.vue";
import ProjectMaterials from "./modular/ProjectMaterials.vue";
import { globalApi } from "@/service";

export default {
  components: {
    HeadSticky,
    TextOverFlow,
    VanSteps,
    DramaView,
    ProjectMaterials,
  },
  setup() {
    const data = reactive({
      info: "测试的VS大是大非擦寄生虫能接受被查不擦采纳数今年初测试的VS大是大非擦寄生虫能接受被查不擦采纳数今年初测试的VS大是大非擦寄生虫能接受被查不擦采纳数今年初测试的VS大是大非擦寄生虫能接受被查不擦采纳数今年初测试的VS大是大非擦寄生虫能接受被查不擦采纳数今年初测试的VS大是大非擦寄生虫能接受被查不擦采纳数今年初测试的VS大是大非擦寄生虫能接受被查不擦采纳数今年初",
      expanded: false,
      bgColor: [],
      dramaInfo: {
        titleName: "演职人员",
      },
      previewInfo: {
        titleName: "剧照/预告片",
      },
      checked: true,
      backColor: [
        [123, 99, 90],
        [88, 54, 44],
      ],
    });

    onBeforeMount(() => {
      let params = {
        projectId: 2,
      };
      globalApi
        .proInfo(params)
        .then((res) => {
          console.log("-----res", res);
        })
        .catch(() => {})
        .finally(() => {});
    });

    /**
     * 色值转换
     */
    const rgb2hex = (sRGB) => {
      const rgb = sRGB.replace(/(?:\(|\)|rgb|RGB)*/g, "").split(",");
      return (
        "#" +
        (
          (1 << 24) +
          (Number(rgb[0]) << 16) +
          (Number(rgb[1]) << 8) +
          Number(rgb[2])
        )
          .toString(16)
          .slice(1)
          .toUpperCase()
      );
    };

    /**
     * 设置背景颜色
     */
    const setBackgroud = () => {
      const Block = document.querySelector("#h5-box");
      Block.style.cssText = "width: 100%;margin-right: 1.3333vw;";
      const col2 =
        "(" +
        rgb2hex(`rgb${data.backColor[0]}`) +
        "," +
        rgb2hex(`rgb${data.backColor[1]}`) +
        "," +
        rgb2hex(`rgb${data.backColor[0]}`) +
        ")";
      Block.style.background = `-webkit-linear-gradient${col2}`;
      console.log("Block", Block);
    };

    /**
     * 设置颜色方法
     */
    const SetColor = (colorArr) => {
      console.log("取色:", colorArr);
      // 初始化删除多余子节点
      const extractColor = document.querySelector("#extract-color-id");
      while (extractColor.firstChild) {
        extractColor.removeChild(extractColor.firstChild);
      }

      // 创建子节点
      for (let index = 0; index < colorArr.length; index++) {
        const bgc =
          "(" +
          Math.floor(colorArr[index][0]) +
          "," +
          Math.floor(colorArr[index][1]) +
          "," +
          Math.floor(colorArr[index][2]) +
          ")";
        /**
         * 色值选择器
         */
        // const colorBlock = document.createElement("div");
        // colorBlock.id = `color-block-id${index}`;
        // colorBlock.style.cssText =
        //   "height: 6.6667vw;width: 6.6667vw;margin-right: 1.3333vw;border-radius: 50%;";
        // colorBlock.style.backgroundColor = `rgb${bgc}`;
        // extractColor.appendChild(colorBlock);
        // 取到的色值存储数组
        data.bgColor.push(bgc);
      }
      const Block = document.querySelector("#h5-box");
      Block.style.cssText = "width: 100%;margin-right: 1.3333vw;";

      // Block.style.backgroundColor = `rgb${data.bgColor[2]}`; // 可以使,无渐变
      /**
       *  data.bgColor获取动态颜色值
       *  rgb2hex 转换色值
       */
      const col2 =
        "(" +
        rgb2hex(`rgb${data.bgColor[0]}`) +
        "," +
        rgb2hex(`rgb${data.bgColor[2]}`) +
        "," +
        rgb2hex(`rgb${data.bgColor[6]}`) +
        ")";
      Block.style.background = `-webkit-linear-gradient${col2}`;
      console.log("Block", Block);
    };

    onMounted(() => {
      /**
       *根据图片获取颜色
       */
       const img = new Image();
       // img.src = `图片的地址`;
       img.src = `//img10.360buyimg.com/n2/s240x240_jfs/t1/210890/22/4728/163829/6163a590Eb7c6f4b5/6390526d49791cb9.jpg!q70.jpg`;
       img.crossOrigin = "anonymous";
       img.onload = () => {
         themeColor(50, img, 20, SetColor);
       };

      setBackgroud();
    });

    const refData = toRefs(data);
    return {
      data,
      ...refData,
    };
  },
};
</script>

参考:
https://github.com/lokesh/color-thief/
https://link.segmentfault.com/
https://www.yuque.com/along-n3gko/ezt5z9/yogm17
https://cloud.tencent.com/developer/article/1132389
https://xcoder.in/2014/09/17/theme-color-extract/