WebP Cloud Services Blog

浏览器是如何实现 CSS 滤镜的?兼谈如何使用 govips 实现同样的滤镜效果

· Nova Kwok

熟悉 CSS 的你一定知道 CSS 有很多滤镜效果,应用这些滤镜,配合渐变和混合模式我们能够做出 Instagram 滤镜一样的效果。

使用 CSS 滤镜真的非常简单,只需要加上 filter 属性,然后传入函数和参数就好了。比如我想调整图像的对比度,那么只需要

filter: contrast(200%);

甚至还可以组合使用

filter: contrast(175%) brightness(103%) sepia(0.8);

放在 HTML 中一个比较完整一点的例子就是这样:

<!DOCTYPE html>
<html>
<head>
<style>
img {
  filter: contrast(175%) brightness(103%) sepia(0.8);
}
</style>
</head>
<body>

<img src="pineapple.jpg" alt="Pineapple" width="300" height="300">

</body>
</html>

大多数情况下,我们知道怎么用这些滤镜就好了。

但是,我们的新项目 WebP Cloud 近期增加了对图片滤镜的支持,而且我们是要将图片转换成加了滤镜的样子(而不是前端套用一个滤镜)

在开发这个功能的时候,我们就要「知其然,知其所以然」。我们不仅要学习如何组合滤镜做出好看的效果,还要了解浏览器是如何实现滤镜效果的,然后再把一样的理论应用到 libvips/govips 之中。

那么回到最开始的问题,浏览器是如何实现某个滤镜的?

W3C

万维网联盟,他们是一个标准化组织,制定了一系列标准并督促网络应用开发者和内容提供者遵循这些标准。标准的内容包括使用语言的规范,开发中使用的导则和解释引擎的行为等等。CSS 自然也是他们定义的。

W3C的官网上,我们能够找到 CSS SPEC­I­FI­CA­TIONS 。最新的2023快照可以在这里看到。

CSS滤镜参考实现

我们以 contrast 滤镜为例,在实际使用中,用法是这样的:

filter: contrast(200%);

下面是 contrast 函数的SVG等效表达式,由 W3C 给出。在这里我们能看到这个 contrast 函数接受的参数:

<filter id="contrast">
  <feComponentTransfer>
      <feFuncR type="linear" slope="[amount]" intercept="-(0.5 * [amount]) + 0.5"/>
      <feFuncG type="linear" slope="[amount]" intercept="-(0.5 * [amount]) + 0.5"/>
      <feFuncB type="linear" slope="[amount]" intercept="-(0.5 * [amount]) + 0.5"/>
  </feComponentTransfer>
</filter>

我们来解析上面的代码:

  • feFuncRfeFuncGfeFuncB:我们可以把他们当作一个个函数来看待,feFuncR 表示操作对应像素的 R 通道数值,G 和 B 依次类推。

  • type 表示这个函数的类型。由于 type 值为 linear,即线性函数,因此函数包含 slopeintercept 属性:

    • slope 表示斜率;

    • intercept 表示截距。

线性函数

上述 contrast 代码中的每个属性,我们可以用更加易于理解的数学知识来理解:

For linear, the function is defined by the following linear equation: C' = slope * C + intercept

简单点,想想你在初中学习的一次函数:形如 y = ax + b,a 是斜率,b 是截距。函数图像如下图所示:

例如上文中提升对比度实际上就是对每个像素在做线性函数的计算。

我们都知道大多数人用的颜色是由 RGB 构成的,至少在 RGB 色彩空间下是这样的。

在 SVG 中,我们有feFuncR feFuncG feFuncB三个函数,正好对应RGB三个值。

slope 代表斜率,也就是数学公式中的 a,在上述 SVG 代码中是 amount

intercept 代表截距,也就是数学公式中的 b,在上述 SVG 代码中是 -(0.5 * [amount]) + 0.5

这样你的数学公式已经有了,y = amount * x + (-(0.5 * amount)) + 0.5 (为了后续计算和理解方便,我们暂时不简化这个表达式)

用户输入 contrast(200%) ,那么amount就是2,我们的公式则为

y = 2x + (-(0.5 * 2)) + 0.5

对于任何一个给定的红色值 x,都可以计算出一个新的红色值 y,绿色和蓝色同理。每个像素都处理完成之后,合并到一起,图片就变成了加了滤镜之后的新的模样。

govips 的实现

既然我们已经知道了对比度滤镜背后的原理,那么就可以用 govips 来实际操作了!幸运的是 govips 提供了 Linear 的 binding,这大大的降低了我们的工作量。如下代码是设置对比度为200%:

slope := []float64{2}
intercept := []float64{-(128 * 2) + 128}
_ = img.Linear(slope, intercept)

在 CSS 中我们使用1来表示完全是这个颜色,在 Go 这里我们用 255 作为等价替换。

slopeintercept 同样都是数组。

哎?我们的数组为什么只有一个元素?这样不会只计算红色吗?答案是不会的,这样做就足够了,因为我们RGB的计算公式是一样的,并且根据libvips的文档

If the arrays of constants have just one element, that constant is used for all image bands.

如果数组里只有一个元素,那么给定的数值会被应用到所有的图像波段(也就是RGB)

Chromium 的实现

理论上这样似乎是有道理的,但是如果不找个浏览器的代码来看看,似乎心里总没底。幸好 Chromium 是开源的,那我们就来扒一扒源代码吧!

render_surface_filters.cc第41行看起来像是对比度相关的代码!

void GetContrastMatrix(float amount, float matrix[20]) {
  memset(matrix, 0, 20 * sizeof(float));
  matrix[0] = matrix[6] = matrix[12] = amount;
  matrix[4] = matrix[9] = matrix[14] = (-0.5f * amount + 0.5f);
  matrix[18] = 1.f;
}

What?怎么弄出来一个矩阵?为什么要把矩阵的0 6 12的值设置为amount;4,9,14的赋值倒是像-(0.5 * [amount]) + 0.5 这个公式,这可以理解……

矩阵

矩阵大概差不多是高中-大学才会接触到的知识。不过不用怕,这里我们不会涉及任何矩阵加减乘运算,也不会涉及线性方程和线性变换。毕竟,您只要有小学或者初中的数学知识就够了!

我们只需要了解两个概念:

  1. 3x2的矩阵的含义是3行2列,用二维数组的形式来表示,是这样的
a   b
c   d
e   f
  1. 在色彩空间中,除了 RGB,还有另外一种 RGBA,A 代表 Alpha 通道,意为图片的不透明参数。
  • RGBA 是一种色彩空间的模型,由 RGB 色彩空间和 Alpha 通道组成。RGBA 代表红(Red)、绿(Green)、蓝(Blue)和Alpha通道(Alpha)。

    Alpha 通道为图像的不透明度参数,其数值可以用百分比、整数或者像RGB参数那样用0到1的实数表示。例如,若一个像素的 Alpha 通道数值为0%,那它就是完全透明的,无法被看见;而数值为 100% 则意味着像素完全不透明,即传统的数码图像。

    对于我们的常见的两个图片格式 JPG 和 PNG 来说, JPG 并不提供 Alpha Channel ,所以 JPG 图片不存在任何「透明」的部分,而 PNG 由于提供了 Alpha Channel,所以图片中可以有透明的部分。哦,对了 WebP/AVIF 等更加现代的图片格式都是支持 Alpha Channel 的。

对于RGB来说,我们对于每个像素的颜色可以用3x3的矩阵表示:

R  G  B
R  G  B
R  G  B

对于RGBA的色彩空间来说,对于每个像素的颜色可以用4x4的矩阵来表示:

R  G  B  A
R  G  B  A
R  G  B  A
R  G  B  A

实践中我们往往会多加一列,用于表示偏移量,这样更加灵活。因为:

  1. 只用3x3或4x4只能表示线性变换,意味着我们可以控制它如何基于输入图像的通道值来放缩和旋转,但是无法为其添加一个偏移量。
  2. 多加了一列之后就变成了3x4或者4x5,这个时候可以添加偏移量来计算了。偏移量也就是上面一直说的截距。

对于 RGBA 的 4x5 来说,是这样的,使用 0 开始的数字来表示,并且加上 RGBA “坐标”方便大家理解。横行就是我们最终需要的结果,竖列可以理解为我们要操作的颜色

     R    G    B   A    Offset
R    0    1    2   3    4
G    5    6    7   8    9
B    10   11   12  13   14
A    15   16   17  18   19

已知斜率是 amount(用缩写a表示),那么对于 RGB 我们的矩阵,对角线的才是R、G和B本身。因此变成了这样

     R    G    B   A    Offset
R    a    1    2   3    4
G    5    a    7   8    9
B    10   11   a   13   14
A    15   16   17  18   19

算法中的 -(0.5 * [amount]) + 0.5 是截距,对于RGB三个颜色来说是同样的公式,截距也就是偏移量,我们用 o 来表示,那么我们的矩阵变成了这样

     R    G    B   A    Offset
R    a    1    2   3    o
G    5    a    7   8    o
B    10   11   a   13   o
A    15   16   17  18   19

用编程语言来表示,如果我们的矩阵是被拍平的m,那么:

m[0]=m[6]=m[12]=amount

m[4]=m[9]=m[14]=-0.5*amount+0.5

我们继续,还差一行。

我们的矩阵是有 alpha 通道的,我们不想改变透明度,我们只想改变颜色通道(RGB),因为在 CSS 标准中提升亮度没有说要操作alpha通道,所以 m[18]=1,同样也是 A 的对角线,1 表示不变。

回过头再看看 Chromium 的代码,是不是发现一模一样了?

恭喜你,学会了最基础的一种矩阵运算🎉

实现sepia滤镜(棕褐色、复古滤镜)

既然我们已经学会了矩阵运算,我们再尝试着实现一下复古滤镜!

SVG

在 W3C 的表中中,sepia 滤镜的定义如下:

<filter id="sepia">
  <feColorMatrix type="matrix"
             values="
    (0.393 + 0.607 * [1 - amount]) (0.769 - 0.769 * [1 - amount]) (0.189 - 0.189 * [1 - amount]) 0 0
    (0.349 - 0.349 * [1 - amount]) (0.686 + 0.314 * [1 - amount]) (0.168 - 0.168 * [1 - amount]) 0 0
    (0.272 - 0.272 * [1 - amount]) (0.534 - 0.534 * [1 - amount]) (0.131 + 0.869 * [1 - amount]) 0 0
    0 0 0 1 0"/>
</filter>

哎也很好理解嘛,还是个矩阵,4x5 的,RGB 三个颜色的计算都不是线性的了,因此每一行结尾的偏移量都是 0;不需要改变图片的透明度,因此第四行第四个元素是1不变。

Chromium的实现

完全照抄了上述 SVG 代码,需要注意 Chromium 在调用者的地方做了 1 - amount

void GetSepiaMatrix(float amount, float matrix[20]) {
  matrix[0] = 0.393f + 0.607f * amount;
  matrix[1] = 0.769f - 0.769f * amount;
  matrix[2] = 0.189f - 0.189f * amount;
  matrix[3] = matrix[4] = 0.f;

  matrix[5] = 0.349f - 0.349f * amount;
  matrix[6] = 0.686f + 0.314f * amount;
  matrix[7] = 0.168f - 0.168f * amount;
  matrix[8] = matrix[9] = 0.f;

  matrix[10] = 0.272f - 0.272f * amount;
  matrix[11] = 0.534f - 0.534f * amount;
  matrix[12] = 0.131f + 0.869f * amount;
  matrix[13] = matrix[14] = 0.f;

  matrix[15] = matrix[16] = matrix[17] = matrix[19] = 0.f;
  matrix[18] = 1.f;
}

govips

要做的事情也是根据用户输入的数字生成一样的矩阵,然后调用某个方法生成图片,这个方法是 Recomb,以 3x3 的 RGB 为例

matrix := [][]float64{
		{0.393 + 0.607*(1-amount), 0.769 - 0.769*(1-amount), 0.189 - 0.189*(1-amount)},
		{0.349 - 0.349*(1-amount), 0.686 + 0.314*(1-amount), 0.168 - 0.168*(1-amount)},
		{0.272 - 0.272*(1-amount), 0.534 - 0.534*(1-amount), 0.131 + 0.869*(1-amount)},
	}
	err := img.Recomb(matrix)
	fmt.Println(err)

你的编辑器和 Go 编译器

可恶! govips 没有 recombines 方法

govips 添加 recomb 方法

既然 govips 没有提供 Recomb 方法,但是在翻了一下 https://github.com/libvips/libvips 库发现其实是有 vips_recomb 函数之后,我们打算自己完成在 govips 上加入调用的功能,为了确保我们的实现没有问题,我们参考了 Nodejs sharp 的源代码和使用方式。

在 Sharp 的代码中,他们的用法是这样:

sharp(input)
  .recomb([
   [0.3588, 0.7044, 0.1368],
   [0.2990, 0.5870, 0.1140],
   [0.2392, 0.4696, 0.0912],
  ])
  .raw()
  .toBuffer(function(err, data, info) {
    // data contains the raw pixel data after applying the matrix
    // With this example input, a sepia filter has been applied
  });

和我们的思路一样,就是传入一个矩阵来操作,学会了,我们开始改造 govips

vips_recomb 的文档在此 https://www.libvips.org/API/current/libvips-conversion.html#vips-recomb,其签名如下:

int
vips_recomb (VipsImage *in,
             VipsImage **out,
             VipsImage *m,
             ...);

recomb 属于 libvipsconversion 中的功能,因为我们需要添加 conversion.c conversion.h 来添加函数原型

// vips/conversion.c

int recomb_image(VipsImage *in, VipsImage **out, VipsImage *m) {
  return vips_recomb(in, out, m, NULL);
}
// vips/conversion.h

int recomb_image(VipsImage *in, VipsImage **out, VipsImage *m);

conversion.go 中,通过 CGO 调用刚刚写好的 recomb_image

// vips/conversion.go

func vipsRecomb(in *C.VipsImage, m *C.VipsImage) (*C.VipsImage, error) {
	incOpCounter("recomb")
	var out *C.VipsImage

	if err := C.recomb_image(in, &out, m); err != 0 {
		return nil, handleImageError(out)
	}

	return out, nil
}

然后再 image.go 中为img添加一个方法,这个方法主要做的事情就是把 Go 的二维数组拍平,转换为 C 的数组,然后调用 vips_image_new_from_memory 生成新的图片。

// vips/image.go

func (r *ImageRef) Recomb(matrix [][]float64) error {
	numBands := r.Bands()
	// Ensure the provided matrix is 3x3
	if len(matrix) != 3 || len(matrix[0]) != 3 || len(matrix[1]) != 3 || len(matrix[2]) != 3 {
		return errors.New("Invalid recombination matrix")
	}
	// If the image is RGBA, expand the matrix to 4x4
	if numBands == 4 {
		matrix = append(matrix, []float64{0, 0, 0, 1})
		for i := 0; i < 3; i++ {
			matrix[i] = append(matrix[i], 0)
		}
	} else if numBands != 3 {
		return errors.New("unsupported number of bands")
	}

	// Flatten the matrix
	var matrixValues []float64
	for _, row := range matrix {
		for _, value := range row {
			matrixValues = append(matrixValues, value)
		}
	}

	// Convert the Go slice to a C array and get its size
	matrixPtr := unsafe.Pointer(&matrixValues[0])
	matrixSize := C.size_t(len(matrixValues) * 8) // 8 bytes for each float64

	// Create a VipsImage from the matrix in memory
	matrixImage := C.vips_image_new_from_memory(matrixPtr, matrixSize, C.int(numBands), C.int(numBands), 1, C.VIPS_FORMAT_DOUBLE)

	// Check for any Vips errors
	errMsg := C.GoString(C.vips_error_buffer())
	if errMsg != "" {
		C.vips_error_clear()
		return errors.New("Vips error: " + errMsg)
	}

	// Recombine the image using the matrix
	out, err := vipsRecomb(r.image, matrixImage)
	if err != nil {
		return err
	}

	r.setImage(out)
	return nil
}

使用

使用 Go mod replace 来使用修改后的代码

module awesomeProject

go 1.21

require github.com/davidbyttow/govips/v2 v2.13.0

require (
	golang.org/x/image v0.5.0 // indirect
	golang.org/x/net v0.7.0 // indirect
	golang.org/x/text v0.7.0 // indirect
)

// may need to run export GOPRIVATE=github.com/webp-sh/govips
replace github.com/davidbyttow/govips/v2 => github.com/webp-sh/govips/v2 v2.13.0

然后正常调用就可以了,比如说

package main

import (
	"fmt"
	"os"

	"github.com/davidbyttow/govips/v2/vips"
)

func main() {
	vips.Startup(nil)
	defer vips.Shutdown()

	img, _ := vips.NewImageFromFile("test.png")
	matrix := [][]float64{
		{0.3588, 0.7044, 0.1368},
		{0.2990, 0.5870, 0.1140},
		{0.2392, 0.4696, 0.0912},
	}

	err := img.Recomb(matrix)
	if err != nil {
		fmt.Println(err)
	}
	image1bytes, _, _ := img.ExportPng(vips.NewPngExportParams())
	_ = os.WriteFile("experiment.png", image1bytes, 0644)

}

效果图

在线网站的效果图

使用 govips 的效果图 几乎一模一样有木有。

Pull Request

我们给 govips 提了一个 Pull Request,希望他们能够合并这个功能,毕竟我们也不想一直使用 replace,同时我们也希望更多有类似需求的人可以直接使用到这个方便的 Recomb 功能。

截至发文为止还没有人回复,因此如果有人也想要使用 Recomb 方法,那么可以在你的 go.mod 中replace一下来使用我们的这个 Fork。

也许在不远的未来,我们会为 govips 添加更多的功能,比如加入常见的 CSS 滤镜一键调用,不用再去参考 W3C 的文档来判断对应的矩阵怎么写,也不用手动构造矩阵来做 Recomb;也相当于为 https://github.com/libvips/libvips 的整个社区贡献了自己的一份力量,这才是开源的真正意义。

WebP Cloud的应用

我们花了大量时间去研究CSS滤镜背后的算法,是因为我们最近为 WebP Cloud 加入了水印和滤镜功能。

其中水印功能非常适合放在网站上展示图片并不希望自己的图片被其他人随意盗用的时候,我们也提供了两种用法:

  • 在 Dashboard 上创建一个水印的配置文件,然后应用到自己的 Proxy 上,这样这个 Proxy 输出的所有图片都会带上水印
  • 如果只是为了尝试一下,或者只有部分图片有添加水印的需求的话,可以通过在图片后面添加 Params 来完成,类似如下这样:
https://559a238.webp.ee/images/create-proxy.png
?visual_effect=watermark,
text__5oiR55qE5LiW55WM5piv5LuA5LmI,
width__0.1,
height__0.1,
offset_x__0.23,
offset_y__0.34,
opacity__1,
color__001489,
font__WGlhb2xhaVND
Original ImageWatermarked Image

关于滤镜的部分,我们目前已经支持了十几种滤镜(1977, aden, brannan, brooklyn, clarendon, earlybird, gingham, hudson 等等),并且还在不断优化之中,远多于Instagram提供的滤镜。和水印一样,你可以直接在 Dashboard 上给 Proxy 应用滤镜让所有图片都自带滤镜效果,或者,你也可以直接在 URL 后面加上参数来使用,类似这样:

https://559a238.webp.ee/images/create-proxy.png
?visual_effect=filter,
name__moon
Original ImageFiltered Image
https://559a238.webp.ee/images/create-proxy.pnghttps://559a238.webp.ee/images/create-proxy.png?visual_effect=filter,name__moon

和往常我们添加的新功能一样,WebP Cloud 一直坚信所有用户应该获得同等的使用权利。因此无论你是付费用户还是免费用户,水印和滤镜的功能均可无限使用。你可以选择全局应用数个水印或滤镜到一个代理,也可以选择在URL参数中尝鲜。

如果你有兴趣来试试,或者希望看到加上了滤镜/水印后的图片长什么样子,欢迎来看看我们的文档: https://docs.webp.se/webp-cloud/visual-effects/

参阅


WebP Cloud Services 团队是一个来自上海和赫尔辛堡的三人小团队,由于我们不融资,且没有盈利压力 ,所以我们会坚持做我们认为正确的事情,力求在我们的资源和能力允许范围内尽量把事情做到最好, 同时也会在不影响对外提供的服务的情况下整更多的活,并在我们产品上实践各种新奇的东西。

如果你觉得我们的这个服务有意思或者对我们服务感兴趣,欢迎登录 WebP Cloud Dashboard 来体验,如果你好奇它还有哪些神奇的功能,可以来看看我们的文档 WebP Cloud Services Docs,希望大家玩的开心~


Discuss on Hacker News