Skip to content

实现 图片滤镜与黑白处理 工具

分类: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-inputel-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 效果截图

图片滤镜与黑白处理 效果截图

工具访问地址:https://www.i91tools.com/tools/image-filter