Appearance
实现 图片滤镜与黑白处理 工具
分类:developer | 标签:滤镜、filter、黑白 亮度对比度饱和度模糊等滤镜调节
2.1 功能说明
亮度对比度饱和度模糊等滤镜调节
2.1.1 使用指南
功能说明
在浏览器本地对图片做实时调色与滤镜处理,支持亮度、对比度、饱和度、色相、模糊、锐化、复古(褐色)、 灰度(黑白)与反相九类调整,左右并排对比原图与效果图,满意后导出 PNG 或 JPEG。 所有运算基于 Canvas 2D,不上传图片、不发起任何网络请求。
参数说明
亮度:整体提亮或压暗,100% 为原始亮度。逆光照片可以适当提高到 110%~130%。
对比度:拉开明暗差距。数值过高会丢失暗部与高光细节,建议控制在 90%~140%。
饱和度:色彩浓淡。设为 0% 等价于去色(黑白)。
色相旋转:沿色轮整体旋转颜色,用来做冷暖调偏移或者特殊风格。
模糊:高斯模糊半径(像素),可用于做背景虚化或柔光底。
复古:sepia 褐色调,配合略降饱和度可以模拟老照片。
黑白:grayscale 去色强度,100% 为完全黑白。顶部的「一键黑白」会直接把黑白拉满并把饱和度归零。
锐化:使用 3×3 卷积核(中心 5,四邻 -1)按强度与原图混合,可以让轮廓更清晰。强度过高会出现白边和噪点。
关于预览与导出的精度
为了保证滑块拖动时的流畅度,预览画面最长边会被限制在 1200 像素以内; 点击导出时会按图片的原始分辨率重新完整跑一遍同样的处理流程, 所以导出结果的清晰度不受预览缩放影响。超大图片(如 6000 像素以上)开启锐化时导出会稍慢,属正常现象。
导出格式选择
PNG:无损,保留透明通道,适合图标、截图、需要二次编辑的素材。
JPEG:有损但体积小,可调质量,适合照片。注意 JPEG 不支持透明,透明区域会被填充为白色。
浏览器兼容性
滤镜依赖 Canvas 的 filter 属性,Chrome、Edge、Firefox 与 Safari 15 以上均支持。若发现调节无效,请升级浏览器。
2.2 代码实现
2.2.1 组件结构
本工具是一个基于 Vue 3 <script setup> 语法的单文件组件(SFC),统一包裹在 ToolPageShell 组件内,由它提供页面标题、工具 ID、分类与「使用指南」插槽等通用外壳;核心业务逻辑(响应式状态、计算属性、事件处理函数)全部写在 <script setup> 中,输入/输出通过 el-input、el-button 等 Element Plus 组件与用户交互,所有数据均在浏览器本地处理,不会上传服务器。
2.2.2 核心逻辑概览
<script setup> 中定义的主要函数/方法:
pickFile()onFileChange()onDrop()loadFile()drawOrigin()applySharpen()renderTo()renderPreview()schedule()applyPreset()toBlackWhite()resetAll()baseName()downloadBlob()exportImage()
2.2.3 关键实现代码
以下是该工具的完整 <script setup> 实现(核心代码)。模板(<template>)与样式(<style>)已省略。
vue
import { ref, computed, watch, onBeforeUnmount, nextTick } from 'vue'
import ToolPageShell from '@/components/ToolPageShell.vue'
const MAX_PREVIEW = 1200
const fileInput = ref(null)
const originCanvas = ref(null)
const resultCanvas = ref(null)
const dragOver = ref(false)
const error = ref('')
const loaded = ref(false)
const rendering = ref(false)
const exporting = ref(false)
const fileName = ref('')
const imgW = ref(0)
const imgH = ref(0)
const previewW = ref(0)
const previewH = ref(0)
const brightness = ref(100)
const contrast = ref(100)
const saturate = ref(100)
const hue = ref(0)
const blur = ref(0)
const sepia = ref(0)
const gray = ref(0)
const invert = ref(0)
const sharpen = ref(0)
const viewMode = ref('side')
const exportFormat = ref('png')
const quality = ref(92)
const activePreset = ref('origin')
let imageEl = null
let objectUrl = ''
const presets = [
{ key: 'origin', label: '原图', v: { brightness: 100, contrast: 100, saturate: 100, hue: 0, blur: 0, sepia: 0, gray: 0, invert: 0, sharpen: 0 } },
{ key: 'bw', label: '经典黑白', v: { brightness: 105, contrast: 115, saturate: 0, hue: 0, blur: 0, sepia: 0, gray: 100, invert: 0, sharpen: 15 } },
{ key: 'retro', label: '复古胶片', v: { brightness: 105, contrast: 95, saturate: 85, hue: 0, blur: 0, sepia: 55, gray: 0, invert: 0, sharpen: 0 } },
{ key: 'cool', label: '冷调', v: { brightness: 100, contrast: 110, saturate: 115, hue: 200, blur: 0, sepia: 0, gray: 0, invert: 0, sharpen: 10 } },
{ key: 'warm', label: '暖调', v: { brightness: 105, contrast: 105, saturate: 120, hue: 15, blur: 0, sepia: 20, gray: 0, invert: 0, sharpen: 0 } },
{ key: 'punch', label: '高对比', v: { brightness: 100, contrast: 150, saturate: 130, hue: 0, blur: 0, sepia: 0, gray: 0, invert: 0, sharpen: 30 } },
{ key: 'soft', label: '柔光', v: { brightness: 110, contrast: 92, saturate: 95, hue: 0, blur: 1.5, sepia: 0, gray: 0, invert: 0, sharpen: 0 } },
{ key: 'ink', label: '素描墨稿', v: { brightness: 115, contrast: 165, saturate: 0, hue: 0, blur: 0, sepia: 0, gray: 100, invert: 0, sharpen: 60 } }
]
const filterString = computed(() => {
const parts = []
parts.push('brightness(' + brightness.value + '%)')
parts.push('contrast(' + contrast.value + '%)')
parts.push('saturate(' + saturate.value + '%)')
if (hue.value !== 0) parts.push('hue-rotate(' + hue.value + 'deg)')
if (blur.value > 0) parts.push('blur(' + blur.value + 'px)')
if (sepia.value > 0) parts.push('sepia(' + sepia.value + '%)')
if (gray.value > 0) parts.push('grayscale(' + gray.value + '%)')
if (invert.value > 0) parts.push('invert(' + invert.value + '%)')
return parts.join(' ')
})
function pickFile() {
if (fileInput.value) fileInput.value.click()
}
function onFileChange(e) {
const f = e.target.files && e.target.files[0]
if (f) loadFile(f)
e.target.value = ''
}
function onDrop(e) {
dragOver.value = false
const f = e.dataTransfer.files && e.dataTransfer.files[0]
if (f) loadFile(f)
}
function loadFile(file) {
error.value = ''
if (!file.type || !file.type.startsWith('image/')) {
error.value = '请选择图片文件(JPG / PNG / WEBP / GIF / BMP)'
return
}
if (objectUrl) URL.revokeObjectURL(objectUrl)
objectUrl = URL.createObjectURL(file)
fileName.value = file.name
const img = new Image()
img.onload = () => {
imageEl = img
imgW.value = img.naturalWidth
imgH.value = img.naturalHeight
const scale = Math.min(1, MAX_PREVIEW / Math.max(img.naturalWidth, img.naturalHeight))
previewW.value = Math.max(1, Math.round(img.naturalWidth * scale))
previewH.value = Math.max(1, Math.round(img.naturalHeight * scale))
loaded.value = true
nextTick(() => {
drawOrigin()
renderPreview()
})
}
img.onerror = () => {
error.value = '图片解码失败,请换一张图片再试'
loaded.value = false
}
img.src = objectUrl
}
function drawOrigin() {
const cv = originCanvas.value
if (!cv || !imageEl) return
cv.width = previewW.value
cv.height = previewH.value
const ctx = cv.getContext('2d')
ctx.clearRect(0, 0, cv.width, cv.height)
ctx.drawImage(imageEl, 0, 0, cv.width, cv.height)
}
function applySharpen(ctx, w, h, amount) {
const src = ctx.getImageData(0, 0, w, h)
const out = ctx.createImageData(w, h)
const s = src.data
const d = out.data
const kernel = [0, -1, 0, -1, 5, -1, 0, -1, 0]
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const i = (y * w + x) * 4
for (let ch = 0; ch < 3; ch++) {
let sum = 0
let ki = 0
for (let ky = -1; ky <= 1; ky++) {
const yy = Math.min(h - 1, Math.max(0, y + ky))
for (let kx = -1; kx <= 1; kx++) {
const xx = Math.min(w - 1, Math.max(0, x + kx))
sum += s[(yy * w + xx) * 4 + ch] * kernel[ki]
ki++
}
}
const origin = s[i + ch]
let v = origin + (sum - origin) * amount
if (v < 0) v = 0
if (v > 255) v = 255
d[i + ch] = v
}
d[i + 3] = s[i + 3]
}
}
ctx.putImageData(out, 0, 0)
}
function renderTo(canvas, w, h) {
canvas.width = w
canvas.height = h
const ctx = canvas.getContext('2d', { willReadFrequently: sharpen.value > 0 })
ctx.clearRect(0, 0, w, h)
ctx.filter = filterString.value
ctx.drawImage(imageEl, 0, 0, w, h)
ctx.filter = 'none'
if (sharpen.value > 0) {
try {
applySharpen(ctx, w, h, sharpen.value / 100)
} catch (err) {
error.value = '锐化处理失败:' + (err && err.message ? err.message : '未知错误')
}
}
}
function renderPreview() {
const cv = resultCanvas.value
if (!cv || !imageEl) return
rendering.value = true
renderTo(cv, previewW.value, previewH.value)
rendering.value = false
}
let timer = null
function schedule() {
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
timer = null
renderPreview()
}, 60)
}
watch([brightness, contrast, saturate, hue, blur, sepia, gray, invert, sharpen], () => {
activePreset.value = ''
if (loaded.value) schedule()
})
watch(viewMode, () => {
if (!loaded.value) return
nextTick(() => {
drawOrigin()
renderPreview()
})
})
function applyPreset(p) {
const v = p.v
brightness.value = v.brightness
contrast.value = v.contrast
saturate.value = v.saturate
hue.value = v.hue
blur.value = v.blur
sepia.value = v.sepia
gray.value = v.gray
invert.value = v.invert
sharpen.value = v.sharpen
nextTick(() => {
activePreset.value = p.key
})
}
function toBlackWhite() {
gray.value = 100
saturate.value = 0
sepia.value = 0
invert.value = 0
}
function resetAll() {
applyPreset(presets[0])
}
function baseName() {
const n = fileName.value || 'image'
const dot = n.lastIndexOf('.')
return dot > 0 ? n.slice(0, dot) : n
}
function downloadBlob(blob, name) {
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = name
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
setTimeout(() => URL.revokeObjectURL(url), 1000)
}
function exportImage() {
if (!imageEl) return
exporting.value = true
error.value = ''
// 让 loading 状态先渲染出来,再执行可能较重的像素运算
setTimeout(() => {
try {
const canvas = document.createElement('canvas')
renderTo(canvas, imgW.value, imgH.value)
if (exportFormat.value === 'jpeg') {
// JPEG 不支持透明,先铺白底再合成
const merged = document.createElement('canvas')
merged.width = canvas.width
merged.height = canvas.height
const mctx = merged.getContext('2d')
mctx.fillStyle = '#ffffff'
mctx.fillRect(0, 0, merged.width, merged.height)
mctx.drawImage(canvas, 0, 0)
merged.toBlob(
(blob) => {
exporting.value = false
if (blob) downloadBlob(blob, baseName() + '-filtered.jpg')
},
'image/jpeg',
quality.value / 100
)
} else {
canvas.toBlob((blob) => {
exporting.value = false
if (blob) downloadBlob(blob, baseName() + '-filtered.png')
}, 'image/png')
}
} catch (err) {
exporting.value = false
error.value = '导出失败:' + (err && err.message ? err.message : '未知错误')
}
}, 30)
}
onBeforeUnmount(() => {
if (timer) clearTimeout(timer)
if (objectUrl) URL.revokeObjectURL(objectUrl)
})2.3 效果截图
