Skip to content
On this page

如何给图片添加颜色滤镜

开发中有时候需要给图片滤镜来实现业务需求,接下来介绍简单滤镜和复杂滤镜的实现方式

CSS 滤镜

浏览器提供了一些常用的滤镜实现方式,我们可以通过filter:grayscale(0.5)来灰度化图片,常用的还包括blurbrightness等等。这些方法一般只能实现比较固定的视觉效果,如何对图片进行像素级别的处理,来实现一些自定义的滤镜呢?

图片的像素信息

一张 600 * 800 的图片有480000个像素点,Canvas2D 以 4 个通道来存放每个像素点的颜色信息,每个通道是 8 个比特位,也就是 0~255 的十进制数值,4 个通道对应 RGBA 颜色的四个值。后面,我们会用 RGBA 通道来分别代表它们。

获取图片的像素信息

我们可以通过Canvas2D提供的getImageData来获取一张图片的像素信息

ts
function getImageData(img: HTMLImageElement) {
    const canvas = document.createElement("canvas")
    canvas.width = img.width
    canvas.height = img.height
    const ctx = canvas.getContext("2d") as CanvasRenderingContext2D
    ctx.drawImage(img, 0, 0, img.width, img.height)
    return ctx.getImageData(0, 0, img.width, img.height)
}

如何灰度化一张图片?

ts
let img = new Image()
    img.src = "https://storage.beta.custouch.com/res/8100/h4.jpg"
    img.setAttribute("crossorigin", "anonymous")
    imgLoaded(img).then(() => {
        // 灰度
        const imgData = getImageData(img)
        if (imgData) {
            const { data, width, height } = imgData
            for (let i = 0; i < width * height * 4; i += 4) {
                // 获取r(红),g(绿),b(蓝),a(透明度)的值
                const [r, g, b, a] = [data[i], data[i + 1], data[i + 2], data[i + 3]]
                // 灰度处理
                const v = 0.2126 * r + 0.7152 * g + 0.0722 * b
                // 将处理后的像素信息取整
                data.set(
                    [v, v, v, a].map((v) => Math.round(v)),
                    i
                )
            }
        }
    })

这里你可能会觉得奇怪,我们为什么用 0.2126、0.7152 和 0.0722 这三个权重,而不是都用算术平均值 1/3 呢?这是因为,人的视觉对 R、G、B 三色通道的敏感度是不一样的,对绿色敏感度高,所以加权值高,对蓝色敏感度低,所以加权值低。

最后,我们将处理好的数据通过putImageData写回到 Canvas 中去。这样,我们就得到了一张经过灰度化后的图片。

ts
const ctx = canvasRef.value.getContext("2d")
if (!ctx) return
canvasRef.value.width = imgData.width
canvasRef.value.height = imgData.height
ctx.putImageData(imgData, 0, 0)

总结一下

示例

重构代码以扩展其他效果

除了灰度化我们还可以对像素颜色做其他变换,比如增强或减弱某个通道的色值,改变颜色的亮度、对比度、饱和度、色相等等。现在对可复用的逻辑进行封装。

ts
function traverse(
    imageData: ImageData,
    pass: (p: {
        r: number
        g: number
        b: number
        a: number
        index: number
        width: number
        height: number
        x: number
        y: number
    }) => number[]
) {
    const { width, height, data } = imageData
    for (let i = 0; i < width * height * 4; i += 4) {
        const [r, g, b, a] = pass({
            r: data[i] / 255,
            g: data[i + 1] / 255,
            b: data[i + 2] / 255,
            a: data[i + 3] / 255,
            index: i,
            width,
            height,
            x: ((i / 4) % width) / width,
            y: Math.floor(i / 4 / width) / height
        })
        data.set(
            [r, g, b, a].map((v) => Math.round(v * 255)),
            i
        )
    }
    return imageData
}

这样做的好处是,traverse 函数会自动遍历图片的每个像素点,把获得的像素信息传给参数中的回调函数处理。这样,我们就只关注 traverse 函数里面的处理过程就可以了。 比方说,上面的灰度处理过程可以简化成这样:

ts
imgLoaded(img).then(() => {
    ...省略代码
    const imgData = getImageData(img)
    if (imgData) {
        traverse(imgData, ({ r, g, b, a }) => {
            const v = 0.2126 * r + 0.7152 * g + 0.0722 * b
            return [v, v, v, a]
        })
    }
})

在灰度变化的例子中,我们使用的是加权平均的方式来处理像素点,这其实是线性方程组的应用,比方说要改变图片亮度,我们可以把rgb分别乘以一个常量,公式如下:

示例

如果P大于1就变亮,如果P小于1就变暗

ts
...省略代码
traverse(imgData, ({ r, g, b, a }) => {
        const p = 1.2
        return [r*p, g*p, b*p, a]
})
...省略代码

使用像素矩阵通用地改变像素颜色

使用上述线性方程修改会有个问题,每一种效果都要单独写一组方程,比方说我想灰度化后改变亮度就没办法复用这两个单独的处理函数了,所以有没有更好的方法去处理呢?答案是有的,我们先定义一个 4*5 颜色矩阵,让它的第一行决定红色通道,第二行决定绿色通道,第三行决定蓝色通道,第四行决定 Alpha 通道。新的像素就是旧像素向量和颜色矩阵的乘积。

灰度处理能重构为:

ts
function grayscale(p = 1) {
    const r = 0.2126 * p
    const g = 0.7152 * p
    const b = 0.0722 * p
    return [
        r + 1 - p, g, b, 0, 0,
        r, g + 1 - p, b, 0, 0,
        r, g, b + 1 - p, 0, 0,
        0, 0, 0, 1, 0
    ]
}

...省略代码...

traverse(imageData, ({r, g, b, a}) => {
    return transformColor([r, g, b, a], grayscale(1));
});

...省略代码...

这里的p就代表灰度化程度,是完全灰度化,0 是完全不灰度,也就是保持原始色彩。

为了方便处理,我们可以增加处理颜色矩阵的模块。让它包含两个函数,一个是处理颜色矩阵的矩阵乘法运算multiply函数,另一个是将 RGBA 颜色通道组成的向量与颜色矩阵相乘,得到新色值的transformColor函数。

ts
// 将 color 通过颜色矩阵映射成新的色值返回
export function transformColor(color, ...matrix) {
  // 颜色向量与矩阵相乘
  ... 省略的代码
}
// 将颜色矩阵相乘
export function multiply(a, b) {
  // 颜色矩阵相乘
  ...省略的代码
}

根据矩阵运算的性质,我们可以将多次颜色变换的过程,简化为将相应的颜色矩阵相乘,然后用最终的那个矩阵对颜色值进行叉积。这样处理过后多次颜色的变化我们也能游刃有余地处理了。大概的流程如下:

如果需要一个暖色的图片,我们可以使用三个处理函数对图片进行处理:

查看源码
vue
<template>
	<div class="flex items-center">
		<img :src="src" class="w-1/2" />
		<canvas ref="canvasRef" class="w-1/2"></canvas>
	</div>
</template>

<script lang="ts" setup>
	import { onMounted, ref } from 'vue'
	import { transformColor, channel, brightness, saturate } from './color-matrix'
	const canvasRef = ref<HTMLCanvasElement>()
	const src = 'https://storage.beta.custouch.com/res/8100/h4.jpg'
	onMounted(() => {
		imgLoaded(src).then((img: HTMLImageElement) => {
			// 灰度
			const imgData = getImageData(img)
			if (imgData) {
				traverse(imgData, ({ r, g, b, a }) =>
					transformColor(
						[r, g, b, a],
						// 增强红色通道
						channel({ r: 1.2 }),
						// 增强亮度
						brightness(1.2),
						// 增强饱和度
						saturate(1.2)
					)
				)
				if (!canvasRef.value) return
				const ctx = canvasRef.value.getContext('2d')
				if (!ctx) return
				canvasRef.value.width = imgData.width
				canvasRef.value.height = imgData.height
				ctx.putImageData(imgData, 0, 0)
			}
		})
	})

	function getImageData(img: HTMLImageElement) {
		const canvas = document.createElement('canvas')
		canvas.width = img.width
		canvas.height = img.height
		const ctx = canvas.getContext('2d')
		if (ctx) {
			ctx.drawImage(img, 0, 0, img.width, img.height)
			return ctx.getImageData(0, 0, img.width, img.height)
		}
	}

	function traverse(
		imageData: ImageData,
		pass: (p: {
			r: number
			g: number
			b: number
			a: number
			index: number
			width: number
			height: number
			x: number
			y: number
		}) => number[]
	) {
		const { width, height, data } = imageData
		for (let i = 0; i < width * height * 4; i += 4) {
			const [r, g, b, a] = pass({
				r: data[i] / 255,
				g: data[i + 1] / 255,
				b: data[i + 2] / 255,
				a: data[i + 3] / 255,
				index: i,
				width,
				height,
				x: ((i / 4) % width) / width,
				y: Math.floor(i / 4 / width) / height
			})
			data.set(
				[r, g, b, a].map((v) => Math.round(v * 255)),
				i
			)
		}
		return imageData
	}
	function imgLoaded(src: string) {
		let img = new Image()
		img.src = src
		img.setAttribute('crossorigin', 'anonymous')
		return new Promise<HTMLImageElement>((resolve, reject) => {
			if (!img.complete) {
				img.onload = () => {
					resolve(img)
				}
				img.onerror = reject
			} else {
				resolve(img)
			}
		})
	}
</script>

总结

本文介绍了除css滤镜外的自定义滤镜事如何实现的。先是使用直观的线性方程组对图片进行处理,之后为了流程的通用性,使用颜色矩阵和颜色向量的乘积来进行像素映射。

Date: 2022/12/09

Authors: 徐安海

Tags: 图片滤镜、canvas