本篇和大家分享客户端的实现方案:
目前提取图片颜色比较常用的主题色提取算法有:最小差值法、中位切分法、八叉树算法、聚类、色彩建模法等,在这里我选择了中位切分法进行实现。
思路
中位切分法通常是在图像处理中降低图像位元深度的算法,可用来将高位的图转换位低位的图,如将24bit的图转换为8bit的图。我们也可以用来提取图片的主题色,其原理是是将图像每个像素颜色看作是以R、G、B为坐标轴的一个三维空间中的点,由于三个颜色的取值范围为0~255,所以图像中的颜色都分布在这个颜色立方体内,如下图所示:

<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">收益权交易截止: 2022年6月18日</p>
<p class="price-child-cxt">回收收益: 2022年6月28日</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/