基于 Vue3 + Canvas + Web Worker 实现高性能图像黑白转换工具的设计与实现
开篇
本篇文章是对于工具小站-图片转黑白功能的一个总结。该功能相对于之前实现的图片随机色功能来说简单了很多,所以文章的篇幅不会特别长。仅仅对其中重要的几个功能点,做一些讲解。
效果展示
技术栈
前端框架:Vue 3 + Element Plus
状态管理:组件内部状态
图片处理:Web Worker + Canvas API
文件处理:File API、Blob API
打包下载:JSZip
技术亮点
- 双模式处理:单张图片和批量图片转换
- 专业调节:可调节黑白、亮度、对比度
- 历史记录功能:可支持撤销和重做
- 性能优化设计:查找表优化、Web Worker异步处理
- 用户体验优化:图片拖曳上传、转换效果实时预览、支持Ctrl + Z快捷键撤销
核心功能实现
图片转黑白功能实现
- 通用函数
// 图片处理方法
const processImage = (image) => {
return new Promise((resolve, reject) => {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
const img = new Image();
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.postMessage({
imageData,
threshold: settings.value.threshold,
brightness: settings.value.brightness,
contrast: settings.value.contrast,
});
resolve();
};
img.onerror = reject;
img.src = image.originalPreview;
});
};
这个函数是图片处理中的通用函数,目的是将图片在canvas上重新绘制,并获取到图片数据。在完成上面的操作后,将图片数据及调节的配置项一并传递给worker文件,以便后续处理。
- 图像转黑白相关函数
// 处理图片转黑白的 Worker
// 预计算亮度和对比度查找表
const brightnessContrastLookupTable = new Uint8Array(256);
// 初始化查找表
function initLookupTable(brightness, contrast) {
const factor = (259 * (contrast + 255)) / (255 * (259 - contrast));
for (let i = 0; i < 256; i++) {
let value = i;
// 应用亮度
value += brightness;
// 应用对比度
value = factor * (value - 128) + 128;
// 限制在有效范围内
brightnessContrastLookupTable[i] = Math.max(0, Math.min(255, value)) | 0;
}
}
// 使用查找表优化的黑白处理
function processBlackAndWhite(imageData, threshold, brightness, contrast) {
// 初始化亮度和对比度查找表
initLookupTable(brightness, contrast);
const data = imageData.data;
const len = data.length;
// 使用 Uint32Array 视图加速访问
const pixels = new Uint32Array(data.buffer);
const pixelCount = len >> 2;
for (let i = 0; i < pixelCount; i++) {
const offset = i << 2;
// 获取RGB值并应用亮度和对比度
const r = brightnessContrastLookupTable[data[offset]];
const g = brightnessContrastLookupTable[data[offset + 1]];
const b = brightnessContrastLookupTable[data[offset + 2]];
// 计算灰度值 (使用加权平均法)
const gray = (r * 0.299 + g * 0.587 + b * 0.114) | 0;
// 根据阈值决定黑白
const value = gray >= threshold ? 255 : 0;
// 一次性设置RGB值(保持Alpha不变)
pixels[i] = (data[offset + 3] << 24) | // Alpha
(value << 16) | // Red
(value << 8) | // Green
value; // Blue
}
return imageData;
}
// 接收主线程消息
self.onmessage = function(e) {
const { imageData, threshold, brightness, contrast } = e.data;
// 处理图片
const processedData = processBlackAndWhite(
imageData,
threshold,
brightness,
contrast
);
// 返回处理后的数据
self.postMessage(processedData);
}
上面是worker文件的所有逻辑。主要进行了一下的操作:
1.创建了一个用于存储预计算的亮度和对比度值的查找表,便于后面直接使用;
2.使用黑白处理函数processBlackAndWhite()来讲图片转换成黑白色,这里使用了刚刚的查找表,有效的优化了性能;
3.将处理好的图像数据返回给主线程;
撤销功能
这里可以理解为我设置了一个历史栈history,每次撤销都会从栈中弹出最新加入的状态数据,而每次更改状态,都会将该状态数据加入到栈中。
worker.onmessage = (e) => {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
canvas.width = e.data.width;
canvas.height = e.data.height;
ctx.putImageData(e.data, 0, 0);
const imageData = canvas.toDataURL("image/png");
if (activeTab.value === "single" && singleImage.value) {
singleImage.value.processedPreview = imageData;
addToHistory(imageData);
} else {
const image = images.value.find((img) => img.processing);
if (image) {
image.processedPreview = imageData;
image.processing = false;
}
}
processing.value = false;
ElMessage.success("转换完成");
};
const undo = () => {
if (!canUndo.value) return;
historyIndex.value--;
if (singleImage.value) {
if (historyIndex.value < 0) {
singleImage.value.processedPreview = "";
} else {
singleImage.value.processedPreview = history.value[historyIndex.value];
}
singleImage.value = { ...singleImage.value };
}
};
重做功能
重做功能比较简单粗暴,恢复初始化状态即可。
const redo = () => {
if (!canRedo.value) return;
historyIndex.value++;
if (singleImage.value) {
singleImage.value.processedPreview = history.value[historyIndex.value];
singleImage.value = { ...singleImage.value };
}
};
完整代码
BlackOrWhite.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"><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 batch-mode">
<div class="images-list">
<el-scrollbar height="600px">
<div
v-for="(image, index) in images"
:key="index"
class="image-item"
>
<div class="image-comparison">
<div class="image-preview original">
<h3>原图</h3>
<div class="image-container">
<img
:src="image.originalPreview"
:alt="image.file.name"
/>
</div>
</div>
<div class="image-preview processed">
<h3>黑白效果</h3>
<div class="image-container">
<img
v-if="image.processedPreview"
:src="image.processedPreview"
:alt="image.file.name + '(黑白)'"
/>
<div v-else class="placeholder">
<el-icon><picture-rounded /></el-icon>
<span>待处理</span>
</div>
</div>
</div>
</div>
</div>
</el-scrollbar>
</div>
</div>
</el-tab-pane>
</el-tabs>
<!-- 控制面板 -->
<div class="control-panel">
<el-form :model="settings" label-position="top">
<!-- 阈值调节 -->
<el-form-item label="黑白阈值">
<el-slider
v-model="settings.threshold"
:min="0"
:max="255"
:format-tooltip="(val) => `${val}`"
/>
</el-form-item>
<!-- 亮度调节 -->
<el-form-item label="亮度调节">
<el-slider
v-model="settings.brightness"
:min="-100"
:max="100"
:format-tooltip="(val) => `${val > 0 ? '+' : ''}${val}%`"
/>
</el-form-item>
<!-- 对比度调节 -->
<el-form-item label="对比度">
<el-slider
v-model="settings.contrast"
:min="-100"
:max="100"
:format-tooltip="(val) => `${val > 0 ? '+' : ''}${val}%`"
/>
</el-form-item>
</el-form>
<!-- 操作按钮 -->
<div class="action-buttons">
<template v-if="activeTab === 'single'">
<el-button
type="primary"
@click="processSingleImage"
:loading="processing"
:disabled="!singleImage"
>
{{ processing ? "转换中..." : "开始转换" }}
</el-button>
<el-button @click="resetSingleImage">重新选择</el-button>
<el-button
type="success"
@click="downloadSingleImage"
:disabled="!singleImage?.processedPreview"
>
下载图片
</el-button>
<!-- 撤销/重做按钮 -->
<el-button :disabled="!canUndo" @click="undo">
<el-icon><back /></el-icon>
撤销
</el-button>
<el-button :disabled="!canRedo" @click="redo">
<el-icon><right /></el-icon>
重做
</el-button>
</template>
<template v-else>
<el-button
type="primary"
@click="processAllImages"
:loading="batchProcessing"
>
{{ batchProcessing ? "批量转换中..." : "批量转换" }}
</el-button>
<el-button @click="resetImages">重新选择</el-button>
<el-button
type="success"
@click="downloadAllImages"
:disabled="!hasProcessedImages"
>
打包下载
</el-button>
</template>
</div>
</div>
</main>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, watch, computed } from "vue";
import { ElMessage } from "element-plus";
import {
UploadFilled,
PictureRounded,
Back,
Right,
} from "@element-plus/icons-vue";
// Web Worker 实例
let worker = null;
// 状态变量
const activeTab = ref("single");
const singleImage = ref(null);
const images = ref([]);
const processing = ref(false);
const batchProcessing = ref(false);
// 历史记录
const history = ref([]);
const historyIndex = ref(-1);
const maxHistoryLength = 20;
// 处理设置
const settings = ref({
threshold: 128, // 黑白阈值
brightness: 0,
contrast: 0,
});
// 计算属性
const hasProcessedImages = computed(() => {
return images.value.some((img) => img.processedPreview);
});
const hasUnprocessedImages = computed(() => {
return images.value.some((img) => !img.processedPreview);
});
const canUndo = computed(() => {
return historyIndex.value >= 0;
});
const canRedo = computed(() => {
return historyIndex.value >= 0;
});
// 初始化 Web Worker
onMounted(() => {
worker = new Worker(
new URL("@/workers/blackwhite.worker.js", import.meta.url)
);
worker.onmessage = (e) => {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
canvas.width = e.data.width;
canvas.height = e.data.height;
ctx.putImageData(e.data, 0, 0);
const imageData = canvas.toDataURL("image/png");
if (activeTab.value === "single" && singleImage.value) {
singleImage.value.processedPreview = imageData;
addToHistory(imageData);
} else {
const image = images.value.find((img) => img.processing);
if (image) {
image.processedPreview = imageData;
image.processing = false;
}
}
processing.value = false;
ElMessage.success("转换完成");
};
worker.onerror = (error) => {
processing.value = false;
ElMessage.error("处理失败:" + error.message);
console.error(error);
};
// 绑定快捷键
document.addEventListener("keydown", handleKeydown);
});
// 清理
onUnmounted(() => {
if (worker) {
worker.terminate();
}
document.removeEventListener("keydown", handleKeydown);
});
// 快捷键处理
const handleKeydown = (e) => {
if (e.ctrlKey || e.metaKey) {
if (e.key === "z") {
e.preventDefault();
if (e.shiftKey) {
redo();
} else {
undo();
}
}
}
};
// 历史记录相关方法
const addToHistory = (imageData) => {
if (historyIndex.value < history.value.length - 1) {
history.value.splice(historyIndex.value + 1);
}
history.value.push(imageData);
historyIndex.value = history.value.length - 1;
if (history.value.length > maxHistoryLength) {
history.value.shift();
historyIndex.value--;
}
};
const undo = () => {
if (!canUndo.value) return;
historyIndex.value--;
if (singleImage.value) {
if (historyIndex.value < 0) {
singleImage.value.processedPreview = "";
} else {
singleImage.value.processedPreview = history.value[historyIndex.value];
}
singleImage.value = { ...singleImage.value };
}
};
const redo = () => {
if (!canRedo.value) return;
historyIndex.value++;
if (singleImage.value) {
singleImage.value.processedPreview = history.value[historyIndex.value];
singleImage.value = { ...singleImage.value };
}
};
// 文件处理方法
const handleSingleFileChange = (file) => {
const fileObj = file.raw;
if (!fileObj || !fileObj.type.startsWith("image/")) {
ElMessage.error("请上传图片文件");
return;
}
singleImage.value = {
file: fileObj,
originalPreview: URL.createObjectURL(fileObj),
processedPreview: "",
};
// 重置历史记录
history.value = [];
historyIndex.value = -1;
};
const handleBatchFileChange = (file) => {
const files = Array.isArray(file) ? file : [file];
files.forEach((f) => {
const fileObj = f.raw;
if (!fileObj || !fileObj.type.startsWith("image/")) {
ElMessage.error(`${fileObj.name} 不是有效的图片文件`);
return;
}
images.value.push({
file: fileObj,
originalPreview: URL.createObjectURL(fileObj),
processedPreview: "",
processing: false,
});
});
};
// 图片处理方法
const processImage = (image) => {
return new Promise((resolve, reject) => {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
const img = new Image();
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.postMessage({
imageData,
threshold: settings.value.threshold,
brightness: settings.value.brightness,
contrast: settings.value.contrast,
});
resolve();
};
img.onerror = reject;
img.src = image.originalPreview;
});
};
const processSingleImage = async () => {
if (!singleImage.value || processing.value) return;
processing.value = true;
try {
await processImage(singleImage.value);
} catch (error) {
ElMessage.error("处理失败,请重试");
console.error(error);
} finally {
processing.value = false;
}
};
const processAllImages = async () => {
if (!images.value.length || batchProcessing.value) return;
batchProcessing.value = true;
try {
// 处理所有图片,不管是否已处理过
for (const image of images.value) {
image.processing = true;
await processImage(image);
}
ElMessage.success("所有图片处理完成");
} catch (error) {
ElMessage.error("处理过程中出现错误");
console.error(error);
} finally {
batchProcessing.value = false;
}
};
// 下载方法
const downloadSingleImage = () => {
if (!singleImage.value?.processedPreview) return;
const link = document.createElement("a");
const fileName = singleImage.value.file.name.split(".")[0];
link.download = `${fileName}_blackwhite.png`;
link.href = singleImage.value.processedPreview;
link.click();
};
const downloadAllImages = async () => {
try {
const JSZip = (await import("jszip")).default;
const zip = new JSZip();
images.value.forEach((image, index) => {
if (image.processedPreview) {
const base64Data = image.processedPreview.split(",")[1];
const fileName = `${image.file.name.split(".")[0]}_blackwhite.png`;
zip.file(fileName, base64Data, { base64: true });
}
});
const content = await zip.generateAsync({ type: "blob" });
const link = document.createElement("a");
link.href = URL.createObjectURL(content);
link.download = "blackwhite_images.zip";
link.click();
ElMessage.success("打包下载开始");
} catch (error) {
ElMessage.error("下载失败");
console.error(error);
}
};
// 重置方法
const resetSingleImage = () => {
if (singleImage.value) {
URL.revokeObjectURL(singleImage.value.originalPreview);
}
singleImage.value = null;
history.value = [];
historyIndex.value = -1;
};
const resetImages = () => {
images.value.forEach((image) => {
URL.revokeObjectURL(image.originalPreview);
});
images.value = [];
};
// 监听设置变化
watch(
() => settings.value,
(newVal, oldVal) => {
if (activeTab.value === "single" && singleImage.value?.processedPreview) {
if (
newVal.threshold !== oldVal.threshold ||
newVal.brightness !== oldVal.brightness ||
newVal.contrast !== oldVal.contrast
) {
processSingleImage();
}
}
},
{ deep: true }
);
</script>
<style scoped>
.app-container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.app-header {
text-align: center;
margin-bottom: 3rem;
}
.app-header h1 {
font-size: 2.5rem;
font-weight: 600;
color: var(--el-text-color-primary);
margin-bottom: 0.5rem;
}
.subtitle {
color: var(--el-text-color-secondary);
font-size: 1.1rem;
}
.upload-section {
background: var(--el-bg-color);
border-radius: 12px;
padding: 2rem;
box-shadow: var(--el-box-shadow-light);
}
.upload-drop-zone {
border: 2px dashed var(--el-border-color);
border-radius: 8px;
padding: 3rem 1rem;
transition: all 0.3s ease;
}
.upload-drop-zone:hover {
border-color: var(--el-color-primary);
background: rgba(64, 158, 255, 0.04);
}
.upload-icon {
font-size: 3rem;
color: var(--el-text-color-secondary);
margin-bottom: 1rem;
}
.process-section {
display: grid;
grid-template-columns: 1fr;
gap: 2rem;
margin-top: 2rem;
}
.image-comparison {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin-bottom: 1rem;
}
.image-preview {
background: var(--el-bg-color);
border-radius: 12px;
padding: 1rem;
box-shadow: var(--el-box-shadow-light);
}
.image-preview h3 {
text-align: center;
margin-bottom: 1rem;
color: var(--el-text-color-primary);
}
.image-container {
aspect-ratio: 16/9;
display: flex;
align-items: center;
justify-content: center;
background: var(--el-fill-color-lighter);
border-radius: 8px;
overflow: hidden;
}
.image-container img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
color: var(--el-text-color-secondary);
}
.control-panel {
background: var(--el-bg-color);
border-radius: 12px;
padding: 2rem;
box-shadow: var(--el-box-shadow-light);
margin-top: 2rem;
}
.action-buttons {
display: flex;
gap: 1rem;
justify-content: center;
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid var(--el-border-color-lighter);
}
.action-buttons .el-button {
min-width: 100px;
}
.images-list {
width: 100%;
}
.image-item {
margin-bottom: 2rem;
padding-bottom: 2rem;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.image-item:last-child {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
.image-tabs {
margin-bottom: 2rem;
}
.single-mode {
display: flex;
flex-direction: column;
gap: 2rem;
}
.batch-mode {
display: flex;
flex-direction: column;
gap: 2rem;
}
:deep(.el-tabs__nav-wrap::after) {
height: 1px;
background-color: var(--el-border-color-lighter);
}
:deep(.el-tabs__item) {
font-size: 1.1rem;
padding: 0 2rem;
}
:deep(.el-tabs__item.is-active) {
font-weight: 600;
}
.main-content {
max-width: 1200px;
margin: 0 auto;
}
:deep(.el-tabs__nav) {
margin-bottom: 1rem;
}
</style>
blackwhite.worker.js
// 处理图片转黑白的 Worker
// 预计算亮度和对比度查找表
const brightnessContrastLookupTable = new Uint8Array(256);
// 初始化查找表
function initLookupTable(brightness, contrast) {
const factor = (259 * (contrast + 255)) / (255 * (259 - contrast));
for (let i = 0; i < 256; i++) {
let value = i;
// 应用亮度
value += brightness;
// 应用对比度
value = factor * (value - 128) + 128;
// 限制在有效范围内
brightnessContrastLookupTable[i] = Math.max(0, Math.min(255, value)) | 0;
}
}
// 使用查找表优化的黑白处理
function processBlackAndWhite(imageData, threshold, brightness, contrast) {
// 初始化亮度和对比度查找表
initLookupTable(brightness, contrast);
const data = imageData.data;
const len = data.length;
// 使用 Uint32Array 视图加速访问
const pixels = new Uint32Array(data.buffer);
const pixelCount = len >> 2;
for (let i = 0; i < pixelCount; i++) {
const offset = i << 2;
// 获取RGB值并应用亮度和对比度
const r = brightnessContrastLookupTable[data[offset]];
const g = brightnessContrastLookupTable[data[offset + 1]];
const b = brightnessContrastLookupTable[data[offset + 2]];
// 计算灰度值 (使用加权平均法)
const gray = (r * 0.299 + g * 0.587 + b * 0.114) | 0;
// 根据阈值决定黑白
const value = gray >= threshold ? 255 : 0;
// 一次性设置RGB值(保持Alpha不变)
pixels[i] = (data[offset + 3] << 24) | // Alpha
(value << 16) | // Red
(value << 8) | // Green
value; // Blue
}
return imageData;
}
// 接收主线程消息
self.onmessage = function(e) {
const { imageData, threshold, brightness, contrast } = e.data;
// 处理图片
const processedData = processBlackAndWhite(
imageData,
threshold,
brightness,
contrast
);
// 返回处理后的数据
self.postMessage(processedData);
}
注
上面便是图片转黑白功能的所有实现逻辑。在整理这篇博客的时候,我还是有些收获的,比如说之前撤销功能我打算待以后再完善的,趁着这次写博客,也一并实现了。
总之,感谢您的阅读,希望本文能对您有所帮助。
原文地址:https://blog.csdn.net/qq_26854355/article/details/145184593
免责声明:本站文章内容转载自网络资源,如侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!