前端图像处理实战: 基于Web Worker和SIMD优化实现图像转灰度功能
开篇
本篇博客是对于工具小站-图片工具模块-图片转灰度功能的总结。
原理与作用
图像灰度化处理的核心思想是将彩色图像的每个像素转换为灰度值。灰度值通常是根据图像的每个像素的红、绿、蓝(RGB)通道的值按某种加权方式计算出来的。这样可以将彩色图像转换为灰度图像,减少颜色信息,使图像只保留亮度信息。
灰度值的计算方法可以有不同的算法,例如加权平均法、简单平均法等。常见的灰度化算法有:
加权法:根据不同颜色的感知权重来计算灰度值。常见权重为:红色0.2156,绿色0.7152,蓝色0.0722.
平均法:将RGB值平均后作为灰度值。
亮度法:与加权法类似,但使用不同的权重(红色0.299,绿色0.587,蓝色0.114)。
功能实现逻辑
考虑到图片处理时需要计算的数据量较大,为了避免阻塞主线程的渲染,我采用了WebWorker的方式在后台线程中执行图像处理操作。
下面将简单接受一下webworker文件中,主要的功能的实现逻辑代码。
灰度值查找表预计算
为了优化灰度化处理性能,这里首先创建了一个grayLookupTable查找表。这张表预计算了每个RGB值对应的灰度值(按照不同的算法),这样可以在实际处理时直接查找对应的值,避免重复计算。
// 预计算灰度值查找表
const grayLookupTable = new Uint8Array(256 * 3);
const weights = {
weighted: [0.2126, 0.7152, 0.0722],
average: [0.3333, 0.3333, 0.3334],
luminosity: [0.299, 0.587, 0.114]
};
// 初始化查找表
function initLookupTables() {
Object.keys(weights).forEach(method => {
const [r, g, b] = weights[method];
for (let i = 0; i < 256; i++) {
grayLookupTable[i * 3] = i * r;
grayLookupTable[i * 3 + 1] = i * g;
grayLookupTable[i * 3 + 2] = i * b;
}
});
}
initLookupTables();
SIMD优化的灰度处理
在浏览器支持SIMD(单指令多数据)时,使用SMD加速了灰度计算。SIMD允许一次操作多个数据元素,因此可以显著提高性能。
processGrayscaleSIMD方法使用SIMD操作来并行处理图像中的多个像素。具体步骤为:
1.加载权重:将算法选择的RGB权重通过SIMD.Float32x4.splat()方法加载到向量中。
2.处理图像数据:使用SIMD.Float32x4来加载多个像素的数据(每4个像素),进行加权计算,最终得到灰度值。
3.保存结果:将计算后的灰度值存回到图像数据中。
如果浏览器不支持SIMD,则回退到标准的灰度处理方法(见下文)。
// 使用 SIMD 优化的灰度处理(如果浏览器支持)
function processGrayscaleSIMD(imageData, algorithm = 'weighted') {
const data = imageData.data;
const len = data.length;
const [r, g, b] = weights[algorithm];
// 使用 SIMD 优化
if (typeof SIMD !== 'undefined' && SIMD.Float32x4) {
const rWeight = SIMD.Float32x4.splat(r);
const gWeight = SIMD.Float32x4.splat(g);
const bWeight = SIMD.Float32x4.splat(b);
for (let i = 0; i < len; i += 16) {
const rgba0 = SIMD.Float32x4.load(data, i);
const rgba1 = SIMD.Float32x4.load(data, i + 4);
const rgba2 = SIMD.Float32x4.load(data, i + 8);
const rgba3 = SIMD.Float32x4.load(data, i + 12);
const gray0 = SIMD.Float32x4.add(
SIMD.Float32x4.mul(rgba0, rWeight),
SIMD.Float32x4.add(
SIMD.Float32x4.mul(rgba1, gWeight),
SIMD.Float32x4.mul(rgba2, bWeight)
)
);
SIMD.Float32x4.store(data, i, gray0);
SIMD.Float32x4.store(data, i + 4, gray0);
SIMD.Float32x4.store(data, i + 8, gray0);
}
return imageData;
}
return processGrayscaleStandard(imageData, algorithm);
}
标准灰度处理(查找表优化)
本方法为标准灰度处理方法,通过查找表加速了每个像素的灰度值计算。具体步骤如下:
1.查找表选择:根据所选的算法(如加权法、平均值法等),确定使用的查找表的偏移量。
2.遍历图像数据中的每个像素,提取RGB值。
3.查找灰度值:使用查找表中的预计算结果来获得每个像素的灰度值。
4.更新像素值:将计算得到的灰度值赋给RGB通道,保持好Alpha通道不变。
// 标准灰度处理(使用查找表优化)
function processGrayscaleStandard(imageData, algorithm = 'weighted') {
const data = imageData.data;
const len = data.length;
const tableOffset = algorithm === 'weighted' ? 0 : (algorithm === 'average' ? 256 : 512);
// 使用 Uint32Array 视图加速访问
const pixels = new Uint32Array(data.buffer);
const pixelCount = len >> 2;
for (let i = 0; i < pixelCount; i++) {
const offset = i << 2;
const r = data[offset];
const g = data[offset + 1];
const b = data[offset + 2];
// 使用查找表计算灰度值
const gray = (
grayLookupTable[tableOffset + r] +
grayLookupTable[tableOffset + g + 1] +
grayLookupTable[tableOffset + b + 2]
) | 0;
// 一次性设置 RGB 值(保持 Alpha 不变)
pixels[i] = (data[offset + 3] << 24) | // Alpha
(gray << 16) | // Red
(gray << 8) | // Green
gray; // Blue
}
return imageData;
}
亮度和对比度调整
最后便是对于亮度和对比度的调整。adjustBrightnessContrast函数应用了预计算的亮度和对比度查找表。具体步骤如下:
1.计算调整因子:使用对比度因子公式来计算处理图像的对比度。
2.亮度调整:根据传入的亮度值调整每个像素的亮度。
3.对比度调整:根据对比度公式对每个像素进行调整。
4.限制范围:确保调整后的像素值在0到255的有效范围内。
5.批量更新像素值:使用unit32Array加速图像数据更新。
// 优化的亮度和对比度处理
function adjustBrightnessContrast(imageData, brightness, contrast) {
const data = imageData.data;
const len = data.length;
const factor = (259 * (contrast + 255)) / (255 * (259 - contrast));
// 预计算亮度和对比度查找表
const lookupTable = new Uint8Array(256);
for (let i = 0; i < 256; i++) {
let value = i;
// 应用亮度
value += brightness;
// 应用对比度
value = factor * (value - 128) + 128;
// 限制在有效范围内
lookupTable[i] = Math.max(0, Math.min(255, value)) | 0;
}
// 使用 Uint32Array 视图加速访问
const pixels = new Uint32Array(data.buffer);
const pixelCount = len >> 2;
for (let i = 0; i < pixelCount; i++) {
const offset = i << 2;
const r = lookupTable[data[offset]];
const g = lookupTable[data[offset + 1]];
const b = lookupTable[data[offset + 2]];
pixels[i] = (data[offset + 3] << 24) | // Alpha
(r << 16) | // Red
(g << 8) | // Green
b; // Blue
}
return imageData;
}
Worker与主线程通信
// 接收主线程消息
self.onmessage = function(e) {
const { imageData, algorithm, brightness, contrast } = e.data;
// 使用优化后的灰度处理
let processedData = processGrayscaleSIMD(imageData, algorithm);
// 使用优化后的亮度和对比度处理
if (brightness !== 0 || contrast !== 0) {
processedData = adjustBrightnessContrast(processedData, brightness, contrast);
}
// 返回处理后的数据
self.postMessage(processedData);
}
完整代码
- 在项目根目录下新建workers文件夹,并增加grayscale.worker.js文件
// 处理图片转灰度的 Worker
// 预计算灰度值查找表
const grayLookupTable = new Uint8Array(256 * 3);
const weights = {
weighted: [0.2126, 0.7152, 0.0722],
average: [0.3333, 0.3333, 0.3334],
luminosity: [0.299, 0.587, 0.114]
};
// 初始化查找表
function initLookupTables() {
Object.keys(weights).forEach(method => {
const [r, g, b] = weights[method];
for (let i = 0; i < 256; i++) {
grayLookupTable[i * 3] = i * r;
grayLookupTable[i * 3 + 1] = i * g;
grayLookupTable[i * 3 + 2] = i * b;
}
});
}
initLookupTables();
// 使用 SIMD 优化的灰度处理(如果浏览器支持)
function processGrayscaleSIMD(imageData, algorithm = 'weighted') {
const data = imageData.data;
const len = data.length;
const [r, g, b] = weights[algorithm];
// 使用 SIMD 优化
if (typeof SIMD !== 'undefined' && SIMD.Float32x4) {
const rWeight = SIMD.Float32x4.splat(r);
const gWeight = SIMD.Float32x4.splat(g);
const bWeight = SIMD.Float32x4.splat(b);
for (let i = 0; i < len; i += 16) {
const rgba0 = SIMD.Float32x4.load(data, i);
const rgba1 = SIMD.Float32x4.load(data, i + 4);
const rgba2 = SIMD.Float32x4.load(data, i + 8);
const rgba3 = SIMD.Float32x4.load(data, i + 12);
const gray0 = SIMD.Float32x4.add(
SIMD.Float32x4.mul(rgba0, rWeight),
SIMD.Float32x4.add(
SIMD.Float32x4.mul(rgba1, gWeight),
SIMD.Float32x4.mul(rgba2, bWeight)
)
);
SIMD.Float32x4.store(data, i, gray0);
SIMD.Float32x4.store(data, i + 4, gray0);
SIMD.Float32x4.store(data, i + 8, gray0);
}
return imageData;
}
return processGrayscaleStandard(imageData, algorithm);
}
// 标准灰度处理(使用查找表优化)
function processGrayscaleStandard(imageData, algorithm = 'weighted') {
const data = imageData.data;
const len = data.length;
const tableOffset = algorithm === 'weighted' ? 0 : (algorithm === 'average' ? 256 : 512);
// 使用 Uint32Array 视图加速访问
const pixels = new Uint32Array(data.buffer);
const pixelCount = len >> 2;
for (let i = 0; i < pixelCount; i++) {
const offset = i << 2;
const r = data[offset];
const g = data[offset + 1];
const b = data[offset + 2];
// 使用查找表计算灰度值
const gray = (
grayLookupTable[tableOffset + r] +
grayLookupTable[tableOffset + g + 1] +
grayLookupTable[tableOffset + b + 2]
) | 0;
// 一次性设置 RGB 值(保持 Alpha 不变)
pixels[i] = (data[offset + 3] << 24) | // Alpha
(gray << 16) | // Red
(gray << 8) | // Green
gray; // Blue
}
return imageData;
}
// 优化的亮度和对比度处理
function adjustBrightnessContrast(imageData, brightness, contrast) {
const data = imageData.data;
const len = data.length;
const factor = (259 * (contrast + 255)) / (255 * (259 - contrast));
// 预计算亮度和对比度查找表
const lookupTable = new Uint8Array(256);
for (let i = 0; i < 256; i++) {
let value = i;
// 应用亮度
value += brightness;
// 应用对比度
value = factor * (value - 128) + 128;
// 限制在有效范围内
lookupTable[i] = Math.max(0, Math.min(255, value)) | 0;
}
// 使用 Uint32Array 视图加速访问
const pixels = new Uint32Array(data.buffer);
const pixelCount = len >> 2;
for (let i = 0; i < pixelCount; i++) {
const offset = i << 2;
const r = lookupTable[data[offset]];
const g = lookupTable[data[offset + 1]];
const b = lookupTable[data[offset + 2]];
pixels[i] = (data[offset + 3] << 24) | // Alpha
(r << 16) | // Red
(g << 8) | // Green
b; // Blue
}
return imageData;
}
// 接收主线程消息
self.onmessage = function(e) {
const { imageData, algorithm, brightness, contrast } = e.data;
// 使用优化后的灰度处理
let processedData = processGrayscaleSIMD(imageData, algorithm);
// 使用优化后的亮度和对比度处理
if (brightness !== 0 || contrast !== 0) {
processedData = adjustBrightnessContrast(processedData, brightness, contrast);
}
// 返回处理后的数据
self.postMessage(processedData);
}
- 新建vue组件,并引用worker文件,绘制转灰度组件的UI
<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 class="image-actions">
<el-button
type="primary"
@click="processSingleImageInBatch(image)"
:loading="image.processing"
:disabled="!!image.processedPreview"
>
{{ image.processing ? '转换中...' : '开始转换' }}
转换此图
</el-button>
</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-select v-model="settings.algorithm" class="algorithm-select">
<el-option
v-for="algo in algorithms"
:key="algo.value"
:label="algo.label"
:value="algo.value"
/>
</el-select>
</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>
</template>
<template v-else>
<el-button
type="primary"
@click="processAllImages"
:loading="batchProcessing"
:disabled="!hasUnprocessedImages"
>
{{ 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 } from '@element-plus/icons-vue'
// Web Worker 实例
let worker = null
// 状态变量
const images = ref([])
const processing = ref(false)
// 灰度算法选项
const algorithms = [
{ label: '加权平均法(推荐)', value: 'weighted' },
{ label: '平均值法', value: 'average' },
{ label: '亮度法', value: 'luminosity' }
]
// 处理设置
const settings = ref({
algorithm: 'weighted',
brightness: 0,
contrast: 0
})
// 计算属性:是否有已处理的图片
const hasProcessedImages = computed(() => {
return images.value.some(img => img.processedPreview)
})
// 新增状态变量
const activeTab = ref('single')
const singleImage = ref(null)
// 添加批量处理状态
const batchProcessing = ref(false)
// 计算是否有未处理的图片
const hasUnprocessedImages = computed(() => {
return images.value.some(img => !img.processedPreview)
})
// 初始化 Web Worker
onMounted(() => {
worker = new Worker(new URL('@/workers/grayscale.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)
processedPreview.value = canvas.toDataURL('image/png')
processing.value = false
ElMessage.success('转换完成')
}
worker.onerror = (error) => {
processing.value = false
ElMessage.error('处理失败:' + error.message)
console.error(error)
}
})
// 清理 Worker
onUnmounted(() => {
resetSingleImage()
resetImages()
if (worker) {
worker.terminate()
}
})
// 处理单张文件上传
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: ''
}
}
// 处理批量文件上传
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 processSingleImage = async () => {
if (!singleImage.value || processing.value) return
processing.value = true
try {
await processImage(singleImage.value)
ElMessage.success('转换完成')
} catch (error) {
ElMessage.error('处理失败,请重试')
console.error(error)
} finally {
processing.value = false
}
}
// 处理批量模式中的单张图片
const processSingleImageInBatch = async (image) => {
if (image.processing || image.processedPreview) return
image.processing = true
try {
await processImage(image)
ElMessage.success('转换完成')
} catch (error) {
ElMessage.error('处理失败,请重试')
console.error(error)
} finally {
image.processing = false
}
}
// 重置单图状态
const resetSingleImage = () => {
if (singleImage.value) {
URL.revokeObjectURL(singleImage.value.originalPreview)
}
singleImage.value = null
}
// 下载单张处理后的图片
const downloadSingleImage = () => {
if (!singleImage.value?.processedPreview) return
const link = document.createElement('a')
const fileName = singleImage.value.file.name.split('.')[0]
link.download = `${fileName}_grayscale.png`
link.href = singleImage.value.processedPreview
link.click()
}
// 处理文件上传
const handleFileChange = (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: ''
})
})
}
// 批量处理图片
const processAllImages = async () => {
if (!images.value.length || batchProcessing.value) return
batchProcessing.value = true
try {
const unprocessedImages = images.value.filter(img => !img.processedPreview)
for (const image of unprocessedImages) {
image.processing = true
await processImage(image)
image.processing = false
}
ElMessage.success('所有图片处理完成')
} catch (error) {
ElMessage.error('处理过程中出现错误')
console.error(error)
} finally {
batchProcessing.value = 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.onmessage = (e) => {
const resultCanvas = document.createElement('canvas')
const resultCtx = resultCanvas.getContext('2d')
resultCanvas.width = e.data.width
resultCanvas.height = e.data.height
resultCtx.putImageData(e.data, 0, 0)
image.processedPreview = resultCanvas.toDataURL('image/png')
resolve()
}
console.log('imageData',imageData);
worker.postMessage({
imageData,
algorithm: settings.value.algorithm,
brightness: settings.value.brightness,
contrast: settings.value.contrast
})
}
img.onerror = reject
img.src = image.originalPreview
})
}
// 打包下载所有处理后的图片
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]}_grayscale.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 = 'grayscale_images.zip'
link.click()
ElMessage.success('打包下载开始')
} catch (error) {
ElMessage.error('下载失败')
console.error(error)
}
}
// 重置状态
const resetImages = () => {
images.value.forEach(image => {
URL.revokeObjectURL(image.originalPreview)
})
images.value = []
settings.value = {
algorithm: 'weighted',
brightness: 0,
contrast: 0
}
}
// 添加对设置变化的监听,自动重新处理图片
watch(
() => settings.value,
() => {
if (images.value.length && hasProcessedImages.value) {
processAllImages()
}
},
{ 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;
}
.algorithm-select {
width: 100%;
}
.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;
}
/* :deep(.el-button--primary) {
background: var(--el-color-primary-gradient);
border: none;
} */
.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>
效果截图
注
转眼间2024年已经过去了,从2024年3月份开始写下第一篇博客开始,到现在也写了90篇博客了。在接下来的一年里,我会继续保持着分享技术、记录成长的习惯,也会在后面慢慢加入一些更加深入的技术总结(努力每个月更新一篇干货吧)。
感谢阅读!希望大家一起在IT之路上一路狂奔~