开篇
本文基于工具小站中的图片随机色功能整理总结,因该功能比较繁琐,所以文章大概率会比较长。。
本文的网格边缘平滑函数用到了高斯模糊算法,关于高斯模糊的应用,以及模糊的效果,可以参考之前的一篇文章:前端图像处理:基于 Canvas 的图片模糊化实现 。
效果展示
技术栈
- 前端框架:Vue 3 + Element Plus
- 状态管理:组件内部状态
- 图片处理:Web Worker + Canvas API
- 颜色空间转换:RGB、LAB、HSL
- 文件处理:File API、Blob API
- 打包下载:JSZip
主要逻辑代码实现
处理图片通用函数
// 处理单张图片的通用函数
const processImage = (image) => {
return new Promise((resolve, reject) => {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
const img = new Image();
// 创建一个 Promise 来处理 worker 的响应
const workerPromise = new Promise((workerResolve) => {
const messageHandler = (e) => {
if (e.data.id === image.id) {
const { imageData } = e.data;
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
canvas.width = imageData.width;
canvas.height = imageData.height;
ctx.putImageData(imageData, 0, 0);
if (image.id === "single") {
singleImage.value.processedPreview = canvas.toDataURL("image/png");
processing.value = false;
} else {
const targetImage = images.value.find((img) => img.id === image.id);
if (targetImage) {
targetImage.processedPreview = canvas.toDataURL("image/png");
}
}
// 移除消息监听器
worker.removeEventListener("message", messageHandler);
workerResolve();
}
};
// 添加消息监听器
worker.addEventListener("message", messageHandler);
});
img.onload = () => {
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
// 发送数据到 worker 处理,添加 algorithm 参数
worker.postMessage({
imageData,
id: image.id,
algorithm: settings.value.algorithm,
});
// 等待 worker 处理完成
workerPromise.then(resolve).catch(reject);
};
img.onerror = reject;
img.src = image.originalPreview;
});
};
无论是处理单张图片,还是处理批量图片,都调用了这个方法。
这段方法的主要作用获取到图片数据,并将图片获取发送给web worker进行处理,待webworker处理完成并返回数据之后,匹配到图片并更新预览图。
这一块主要是通用性的方法,并不涉及过多的逻辑操作。当web worker接收到图片数据后,根据当前选择的模式,进行了不同的处理。
接下来我们讲讲四种模式的逻辑。
区域随机
- 主函数:processRegionalRandomColor()
function processRegionalRandomColor(imageData, gridSize = 4) {
const width = imageData.width;
const height = imageData.height;
const data = imageData.data;
// 计算每个网格的大小
const cellWidth = Math.floor(width / gridSize);
const cellHeight = Math.floor(height / gridSize);
// 处理每个网格
for (let gridY = 0; gridY < gridSize; gridY++) {
for (let gridX = 0; gridX < gridSize; gridX++) {
// 计算当前网格的边界
const startX = gridX * cellWidth;
const startY = gridY * cellHeight;
const endX = Math.min((gridX + 1) * cellWidth, width);
const endY = Math.min((gridY + 1) * cellHeight, height);
// 收集网格内的像素
const gridPixels = [];
const originalColors = new Map(); // 存储原始颜色
for (let y = startY; y < endY; y++) {
for (let x = startX; x < endX; x++) {
const i = (y * width + x) * 4;
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
const lab = rgbToLab(r, g, b);
gridPixels.push({
index: i,
rgb: [r, g, b],
lab: lab
});
// 记录原始颜色
originalColors.set(i, {
rgb: [r, g, b],
lab: lab
});
}
}
// 对网格内的像素进行聚类
const { centroids, colorMappings } = clusterColors({
data: new Uint8ClampedArray(gridPixels.length * 4),
width: endX - startX,
height: endY - startY
}, 8);
// 为每个聚类生成随机色相偏移
const hueShifts = new Map();
centroids.forEach((_, index) => {
hueShifts.set(index, Math.random() * 360);
});
// 应用颜色映射,保持原始明度
for (const pixel of gridPixels) {
let minDistance = Infinity;
let closestCentroid = 0;
centroids.forEach((centroid, index) => {
const distance = deltaE(pixel.lab, centroid);
if (distance < minDistance) {
minDistance = distance;
closestCentroid = index;
}
});
// 获取原始颜色
const originalColor = originalColors.get(pixel.index);
// 获取色相偏移
const hueShift = hueShifts.get(closestCentroid);
// 转换到 HSL 空间
const hsl = rgbToHsl(originalColor.rgb[0], originalColor.rgb[1], originalColor.rgb[2]);
// 应用色相偏移,保持饱和度和明度
const newHsl = [
(hsl[0] + hueShift) % 360,
hsl[1],
hsl[2]
];
// 转回 RGB
const newRgb = hslToRgb(newHsl[0], newHsl[1], newHsl[2]);
data[pixel.index] = newRgb[0];
data[pixel.index + 1] = newRgb[1];
data[pixel.index + 2] = newRgb[2];
}
}
}
// 平滑网格边界
smoothGridBoundaries(imageData, gridSize);
return imageData;
}
上面这段代码的逻辑,主要是将图像分割成网格,并对每个网格内的颜色进行了处理。
1.计算每个网格的大小
2.使用嵌套循环遍历每个网格,并收集网格内的所有像素,并将它们的RGB、Lab颜色值存储起来,同时保存原始颜色信息
3.对网格内的像素进行颜色聚类
4.为每个聚类生成随机的色相偏移值
5.对网格内的每个像素,都分别进行找到最接近的聚类中心、获取原始颜色和对应的色相偏移、将颜色从RGB转换到HSL空间、应用色相偏移并保持饱和度和明亮度不变、将新的HSL颜色转回RGB,并更新颜色数据
6.调用smoothGridBoundaries方法平滑网格边界
7.将处理好的数据返回给imageData
- 辅助函数:rgbToHsl()
// 添加 RGB 到 HSL 的转换函数
function rgbToHsl(r, g, b) {
r /= 255;
g /= 255;
b /= 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
let h, s, l = (max + min) / 2;
if (max === min) {
h = s = 0;
} else {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r: h = (g - b) / d + (g < b ? 6 : 0); break;
case g: h = (b - r) / d + 2; break;
case b: h = (r - g) / d + 4; break;
}
h /= 6;
}
return [h * 360, s * 100, l * 100];
}
这个函数的目的,是将RGB(红、绿、蓝)颜色空间转换为HSL(色相、饱和度、亮度)颜色空间
- 辅助函数:hslToRgb()
// 添加 HSL 到 RGB 的转换函数
function hslToRgb(h, s, l) {
h /= 360;
s /= 100;
l /= 100;
let r, g, b;
if (s === 0) {
r = g = b = l;
} else {
const hue2rgb = (p, q, t) => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1/6) return p + (q - p) * 6 * t;
if (t < 1/2) return q;
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
return p;
};
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
r = hue2rgb(p, q, h + 1/3);
g = hue2rgb(p, q, h);
b = hue2rgb(p, q, h - 1/3);
}
这个函数的目的是将HSL(色相、饱和度、亮度)颜色空间转换回RGB(红、绿、蓝)颜色空间。其实就是上个函数的反转版。
- 辅助函数:clusterColors()
// 对图片颜色进行聚类
function clusterColors(imageData, maxColors = 16) {
const pixels = [];
const data = imageData.data;
// 收集所有像素的颜色
for (let i = 0; i < data.length; i += 4) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
const lab = rgbToLab(r, g, b);
pixels.push({
rgb: [r, g, b],
lab: lab
});
}
// 使用 K-means 聚类
const centroids = [];
const colorMap = new Map();
// 随机选择初始中心点
for (let i = 0; i < maxColors; i++) {
const randomIndex = Math.floor(Math.random() * pixels.length);
centroids.push(pixels[randomIndex].lab);
}
// 迭代优化聚类
const maxIterations = 10;
for (let iteration = 0; iteration < maxIterations; iteration++) {
const clusters = Array(maxColors).fill().map(() => []);
// 将每个像素分配到最近的中心点
for (const pixel of pixels) {
let minDistance = Infinity;
let closestCentroid = 0;
for (let i = 0; i < centroids.length; i++) {
const distance = deltaE(pixel.lab, centroids[i]);
if (distance < minDistance) {
minDistance = distance;
closestCentroid = i;
}
}
clusters[closestCentroid].push(pixel);
}
// 更新中心点
for (let i = 0; i < maxColors; i++) {
if (clusters[i].length === 0) continue;
const newCentroid = [0, 0, 0];
for (const pixel of clusters[i]) {
newCentroid[0] += pixel.lab[0];
newCentroid[1] += pixel.lab[1];
newCentroid[2] += pixel.lab[2];
}
centroids[i] = [
newCentroid[0] / clusters[i].length,
newCentroid[1] / clusters[i].length,
newCentroid[2] / clusters[i].length
];
}
}
// 为每个聚类生成随机颜色
const colorMappings = new Map();
centroids.forEach((centroid, i) => {
colorMappings.set(i, getRandomColor());
});
return { centroids, colorMappings };
}
这段代码实现了一个基于K-means聚类算法的颜色聚类功能。将图像中的颜色分成若干个主要颜色(最多为maxColors个),并返回这些颜色的Lab颜色空间的中心和与每个中心对应的随机颜色。
代码的逻辑稍微梳理一下就可以明白,这里需要讲一下k-means算法,这是一种聚类方法,目的是将数据分成若干个“簇”。这些簇中的数据点要尽可能的相似,而簇与簇之间要尽可能的不同。而我在这里使用这个算法的目的是什么呢?是为了将图片中同一种颜色转成另一种颜色。比如说图片中的红色,都随机转换成蓝色,而不是有的地方转成蓝色,有的地方转成红色。
- 平滑网格边界函数:smoothGridBoundaries()
// 辅助函数: 平滑网格边界
function smoothGridBoundaries(imageData, gridSize) {
const width = imageData.width;
const height = imageData.height;
const data = imageData.data;
const cellWidth = Math.floor(width / gridSize);
const cellHeight = Math.floor(height / gridSize);
// 创建临时缓冲区
const tempData = new Uint8ClampedArray(data);
// 在网格边界处应用高斯模糊(这里处理的是垂直边界)
const blurRadius = 3;
for (let gridY = 1; gridY < gridSize; gridY++) {
// 确定垂直网格的边界
const y = gridY * cellHeight;
// 遍历每一列像素
for (let x = 0; x < width; x++) {
// 对周围的像素进行高斯加权,这里对dy范围内垂直方向上blurRadius个像素都进行了模糊
for (let dy = -blurRadius; dy <= blurRadius; dy++) {
const cy = y + dy;
if (cy < 0 || cy >= height) continue;
// 对dx范围内水平方向上blurRadius个像素进行模糊
for (let dx = -blurRadius; dx <= blurRadius; dx++) {
const cx = x + dx;
if (cx < 0 || cx >= width) continue;
// 计算高斯权重
const weight = Math.exp(-(dx * dx + dy * dy) / (2 * blurRadius * blurRadius));
// 加权累加像素值
const srcIdx = (cy * width + cx) * 4;
const dstIdx = (y * width + x) * 4;
for (let c = 0; c < 3; c++) {
tempData[dstIdx + c] += data[srcIdx + c] * weight;
}
}
}
}
}
// 在网格边界处应用高斯模糊(这里处理的是水平边界)
for (let gridX = 1; gridX < gridSize; gridX++) {
const x = gridX * cellWidth;
for (let y = 0; y < height; y++) {
for (let dx = -blurRadius; dx <= blurRadius; dx++) {
const cx = x + dx;
if (cx < 0 || cx >= width) continue;
for (let dy = -blurRadius; dy <= blurRadius; dy++) {
const cy = y + dy;
if (cy < 0 || cy >= height) continue;
const weight = Math.exp(-(dx * dx + dy * dy) / (2 * blurRadius * blurRadius));
const srcIdx = (cy * width + cx) * 4;
const dstIdx = (y * width + x) * 4;
for (let c = 0; c < 3; c++) {
tempData[dstIdx + c] += data[srcIdx + c] * weight;
}
}
}
}
}
// 将平滑后的结果复制回原数组
for (let i = 0; i < data.length; i++) {
data[i] = tempData[i];
}
}
这段代码的目的是用来平滑网格边界,目标是在给定的网格的边界处应用高斯模糊,从而使得网格边界更快平滑。也是对于我们是网格化处理图片的一种后续的维护服务。
这里我再讲一下高斯模糊。高斯模糊是一种图像模糊技术,通过对每个像素及其邻近像素赋予不同的权重来实现模糊效果,这种模糊效果可以参考我们之前实现的图像模糊化功能。而这里的权重是什么呢?权重是根据高斯函数(也就是正态分布函数)计算出来的,距离模糊中心越远,像素权重越小。
这个公式就是下面这种
而在我们的方法里,以垂直边界平滑逻辑为例,我会给出极其详尽的注释。
全局随机
// 全局随机
function processRandomColor(imageData) {
// 获取聚类中心和颜色映射
const { centroids, colorMappings } = clusterColors(imageData);
const data = imageData.data;
// 处理每个像素
for (let i = 0; i < data.length; i += 4) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
const pixelLab = rgbToLab(r, g, b);
// 找到最近的聚类中心
let minDistance = Infinity;
let closestCentroid = 0;
centroids.forEach((centroid, index) => {
const distance = deltaE(pixelLab, centroid);
if (distance < minDistance) {
minDistance = distance;
closestCentroid = index;
}
});
// 应用对应的随机颜色
const newColor = colorMappings.get(closestCentroid);
data[i] = newColor[0];
data[i + 1] = newColor[1];
data[i + 2] = newColor[2];
}
return imageData;
}
这段代码的主要逻辑为:
1.对输入的图像进行颜色聚类,找出颜色簇的中心;
2.对图像中的每个像素,根据其颜色与各个聚类中心的距离,找出最接近的聚类中心;
3.用聚类中心对应的随机颜色替换该像素的颜色;
4.返回修改后的图像数据;
所有代码
限于篇幅原因,其他两个模式我就不讲解了,感兴趣的可以自己研究梳理一下。下面给出这个功能涉及到的两个文件的完整代码。
- RandomColor.vue
<template>
<div class="app-container">
<header class="app-header">
<h1>图片随机色</h1>
<p class="subtitle">专业的图片随机色处理工具,支持多种算法</p>
</header>
<main class="main-content">
<!-- 添加标签页 -->
<el-tabs v-model="activeTab" class="image-tabs">
<el-tab-pane label="单张处理" name="single">
<!-- 单张图片处理区域 -->
<div class="upload-section" v-if="!singleImage">
<el-upload
class="upload-drop-zone"
drag
:auto-upload="false"
accept="image/*"
:show-file-list="false"
:multiple="false"
@change="handleSingleFileChange"
>
<el-icon class="upload-icon"><upload-filled /></el-icon>
<div class="upload-text">
<h3>将图片拖到此处,或点击上传</h3>
<p>支持 PNG、JPG、WebP 等常见格式</p>
</div>
</el-upload>
</div>
<div v-else class="process-section single-mode">
<div class="image-comparison">
<!-- 原图预览 -->
<div class="image-preview original">
<h3>原图</h3>
<div class="image-container">
<img
:src="singleImage.originalPreview"
:alt="singleImage.file.name"
/>
</div>
</div>
<!-- 随机色效果预览 -->
<div class="image-preview processed">
<h3>随机色效果</h3>
<div class="image-container">
<img
v-if="singleImage.processedPreview"
:src="singleImage.processedPreview"
:alt="singleImage.file.name + '(随机色)'"
/>
<div v-else class="placeholder">
<el-icon><picture-rounded /></el-icon>
<span>待处理</span>
</div>
</div>
</div>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="批量处理" name="batch">
<!-- 批量处理区域 -->
<div class="upload-section" v-if="!images.length">
<el-upload
class="upload-drop-zone"
drag
:auto-upload="false"
accept="image/*"
:show-file-list="false"
:multiple="true"
@change="handleBatchFileChange"
>
<el-icon class="upload-icon">