WebP Cloud Services Blog

如何使用 govips 实现类似 CSS 中的渐变效果

· Benny Think

This article is also available in English, at How to achieve a gradient effect similar to CSS using govips.

上次的文章 浏览器是如何实现 CSS 滤镜的?兼谈如何使用 govips 实现同样的滤镜效果 我们讲了如何实现部分 CSS 的滤镜,那么同样作为 CSS 的功能之一的渐变,是否能够同样支持呢?答案当然是肯定的啦。

CSS对于渐变的支持非常灵活。为了简化问题,我们这次只以最基础的方式实现两种渐变,对于正常使用来说应该是基本足够的。

为了阅读本文,小学的数学知识可能不够了。本文需要您理解基础的几何知识,并且了解勾股定理和三角函数。

CSS 的渐变分类

CSS 的渐变主要有三种:linear-gradient

radial-gradient

conic-gradient

以上图片来自 MDN

线性渐变

linear-gradient,CSS 支持多种颜色的渐变,并且可以由使用者定义在何处开始渐变,而且还可以设置渐变的角度。

background: linear-gradient(25deg, #3f87a6, #ebf8e1);

为了简单起见,我们先以两种颜色的、从左到右开始、水平角度的渐变。

我们以从左到右、红色变蓝色渐变为例,从左到右的渐变,左侧是红色,右侧是蓝色。越靠近左侧的部分,颜色中红色的成分越多;越靠近右侧,颜色中蓝色的成分越多。中线意味着蓝色和红色各占一半,用 RGB 表示也就是 (127,0,127)。

我们先只考虑 5x1 的图片,这个图片是一个长条。我们可以通过简单的数学只是得出以下结论

对于最左侧的像素,我们用 1 来表示

  1. 红色最多,蓝色为 0,混合起来也就是 (255,0,0)+(0,0,0)=(255,0,0) 纯红色的图片
  2. 红色稍多,蓝色稍少。红色占 75% 蓝色 25%,也就是 255 * 0.75,0, 255 * 0.25 = 191,0,63
  3. 红色蓝色一边多,也就是 (127,0,127)
  4. 蓝色稍多,正好是 2 反过来,也就是 (63,0,191)
  5. 全部是蓝色 没有红色 (0,0,255)

是不是有想法了!这就是一个简单的循环的事嘛! width=5,那么算法也就是:

width := 5
for i := 0; i < width; i++ {
    ratio := float64(i) /float64(width-1)
    fmt.Println(ratio)
}

注意这里 width - 1 了,因为我们希望的 ratio 是 0, 1/4, 2/4 , 3/4 , 1 而不是 0, 1/5, 2/5, 3/5 , 4/5

那么 RGB 的颜色,也就可以用这个 ratio 来计算,每一个像素都要乘以这个 ratio。

startColor := []float64{255, 0, 0}
endColor := []float64{0, 0, 255}

r := uint8(startColor[0]*(1-ratio) + endColor[0]*ratio)
g := uint8(startColor[1]*(1-ratio) + endColor[1]*ratio)
b := uint8(startColor[2]*(1-ratio) + endColor[2]*ratio)

如何创建图片

libvips 提供了挺多方法来创建图片的。但是 govips 封装的比较少,只有 XYZBlack。这里就用 Black 来创建图片吧,简单好用:

img, _ := vips.Black(width, height)
_ = img.ToColorSpace(vips.InterpretationSRGB)

需要注意,我们要把色彩空间转换到 RGB,这样才是彩色图片

如何填充像素

知道了某一个像素值,如何去图片里写这个值呢? govips 提供了一个 DrawRect ,顾名思义,画正方形,原型如下:

DrawRect(ink ColorRGBA, left int, top int, width int, height int, fill bool) 

提供颜色,像素的位置,正方形大小,是否填充,比如我想画一个正方形,左上角开始,大小 10x10,那么就是

img.DrawRect(ink, 0, 0, 10, 10, true)

填充一个像素,也就是

img.DrawRect(ink, 0, 0, 1, 1, true)

创建渐变图片

各种技术难关我们都已经攻克了,那么就只需要组合一下代码就可以了!

func gradient () {
    height := 100
    width := 100

    img := newImage(width, height)

    startColor := []float64{255, 0, 0}
    endColor := []float64{0, 0, 255}

    for y := 0; y < height; y++ {
        for x := 0; x < width; x++ {ratio := float64(x) /float64(width-1)
            r := uint8(startColor[0]*(1-ratio) + endColor[0]*ratio)
            g := uint8(startColor[1]*(1-ratio) + endColor[1]*ratio)
            b := uint8(startColor[2]*(1-ratio) + endColor[2]*ratio)
            _ = img.DrawRect(vips.ColorRGBA{R: r, G: g, B: b, A: 255}, x, y, 1, 1, true)
        }
    }

    ep := vips.NewJpegExportParams()
    image1bytes, _, _ := img.ExportJpeg(ep)
    _ = os.WriteFile("gradient.jpg", image1bytes, 0644)
}

效果不错!

于是你兴致勃勃的把图片宽高改为 1000x1000 。结果…… 等了 10 分钟还没出结果。

也确实难为了我们程序员,1000x1000 就要循环 1000000,也就是一百万次,时间复杂度是 O(n²) 那必然是慢的。

优化线性渐变性能

那么有没有办法优化下性能呢?看下我们的渐变图,从左到右来看,1000x1 和 1000x1000 的区别只是,1000x1000 把 x1 的长条重复了 1000 次,然后成为了矩形。

那我们是否可以生成一个 1000x1 的长条,然后复制 1000 次组合成矩形?这样循环 100 万次变成 1000 次,性能提升没有 1000 倍也有 100 倍。

幸运的是,libvips/govips 提供了一个方法 Replicate , 顾名思义复制图片 N 次。

Replicate(across int, down int) 

across 表示横着复制,down 表示竖着复制。想像成在拼图!用小方块来解释一下

Replicate(5,1)

◼︎◼︎◼︎◼︎◼︎

Replicate(1,5)

◼︎

◼︎

◼︎

◼︎

◼︎

那我们只要 Replicate(1,height) 即可。需要注意,我们需要生成 100x1 的图片而不是 100x100!

height := 100
width := 100

img := newImage(width, 1)

startColor := []float64{255, 0, 0}
endColor := []float64{0, 0, 255}

for x := 0; x < width; x++ {ratio := float64 (x) /float64 (width-1)
	r := uint8(startColor[0]*(1-ratio) + endColor[0]*ratio)
	g := uint8(startColor[1]*(1-ratio) + endColor[1]*ratio)
	b := uint8(startColor[2]*(1-ratio) + endColor[2]*ratio)
	_ = img.DrawRect(vips.ColorRGBA{R: r, G: g, B: b, A: 255}, x, 0, 1, 1, true)
}
_ = img.Replicate(1, height)

ep := vips.NewJpegExportParams()
image1bytes, _, _ := img.ExportJpeg(ep)
_ = os.WriteFile("gradient.jpg", image1bytes, 0644)

在 M1 Pro 的 Mac 上,计算 100 万次耗时 131 秒,复制 1.31 秒,提升了 100 倍不止的性能。

对比效果图

使用如下 HTML 可以简单暴力的生成渐变

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Title</title>
  </head>
  <body class="gradient">
    <div style="width: 1000px; height: 1000px" class="simple-linear"></div>
  </body>
  <style>
    .simple-linear {
      background: linear-gradient (to right, red, blue);
    }
  </style>
</html>

对比一下,基本上一个效果

渐变角度

CSS 的渐变支持自定义角度,比如上面 to right 表示从左至右红色渐变为蓝色。默认为从上至下 to bottom,等价于 180deg;360deg 是下面红色上面蓝色

30deg 这种就是自定义角度了。我们该如何实现任意角度的渐变呢?

对于 90 度整数倍的渐变,无非就是红色从上下左右哪个方向开始,我们只要改变循环 width 还是 height,然后 replicate 横向还是纵向就好了。

但是对于任意角度,这个计算好像就有些麻烦了。

不如我们变通一下,是否可以一个简单的渐变,比如从左至右,然后我们任意角度旋转!这样不仅避免了学习复杂的数学知识,还避免了多次计算。

当然了,这个算法其实挺差劲的。和浏览器的实现方式差远了。

任意角度旋转图片

用 govips 的 Similarity 函数,签名如下:

Similarity(scale float64, angle float64, backgroundColor *ColorRGBA, idx float64, idy float64, odx float64, ody float64) 

scale 缩放比例,angle 角度,backgroundColor 背景颜色(因为任意角度旋转,有 (360-4)/360=98.88% 的概率会出现非长方形的情况,此时需要填充色)

背景颜色先填充一个黑色,,以 200x200 的图片,旋转 23 度为例(特意选择了非 30 45 90 整数倍度数)

bgColor := vips.ColorRGBA{R: 0, G: 0, B: 0, A: 255}
img.Similarity(1, 23, &bgColor, 0, 0, 0, 0)

得到的效果图是这样的,你能找到哪个角是 23° 吗?

看起来角度正确!那么剩下的问题就在于:

  1. 渐变图得再大一点(但是又不能太大),使用者希望生成的图片是 200x200,现在连图片的分辨率都不是 200x200 而是 213x213 了,并且还有黑边
  2. 裁剪中间的 200x200

如何计算大一点的渐变图尺寸

也许你会这样想:

  • 用户想生成 200x200 的渐变图,那我们直接生成比较的图,比如 2 倍大的渐变图,然后取中间的 200x200 就好了嘛! 这样当然不行啦,不仅生成大图需要消耗很多资源,并且这样结果是错误的!想象一下用户要 10x10 的,然后你生成了 5000x5000 的,只取中间 10x10,那不全是紫色了嘛!
  • 那生成 2 倍大,然后按照原图比例,尽可能在有颜色的区域内选择一个矩形,然后把这个矩形缩放回 200x200 想法不错,结果也正确,但是比较浪费计算资源

那么再继续优化一下以上想法,最好的策略应该就是生成不大不小的图片,在中间选择 200x200,任务结束。

那么如何计算这个 “不大不小呢”,粗略的算,可以应用勾股定理 sqrt(width*width + height*height),整体效果差不太多。

如果需要再精准一些,那么此时就需要初高中数学知识:三角函数。

其问题可以简化为如下数学题

已知 x 和 y 的值分别为 1000 和 600,以及 23° 的角。问如何计算黑色矩形的长和高? 也就是说,只要求出来 abcd 的值,长是 d+a,高是 b+c

我们先来复习一下三角函数吧!以正弦、余弦、正切为例

  • θ 的正弦是对边与斜边的比值: sin θ = a/h
  • θ 的余弦是邻边与斜边的比值: cos θ = b/h
  • θ 的正切是对边与邻边的比值: tan θ = a/b

也就是说,

  • 高:c+b=x * cos (90-23) + y * cos23
  • 宽:d+a=x * sin (90-23) + y * sin23

计算代码如下,需要注意角度与弧度的区别,2π=180°,为了避免精度问题,math.Ceil 向上取整一下,免得出现黑边什么的

angle := 23.0
width := 1000
height := 600
r := angle / 180 * math.Pi
r2 := (90 - angle) / 180 * math.Pi

newHeight := math.Ceil(float64(width)*math.Cos(r2) + float64(height)*(math.Cos(r)))
newWidth := math.Ceil(float64(width)*math.Sin(r2) + float64(height)*math.Sin(r))

fmt.Printf("new width %.2f, new height: %.2f", newWidth, newHeight)

输出的结果为 new width 1155.00, new height: 944.00

没错,我们算出来的结果和这个示例图片的分辨率基本一样的,如果不一样那就是上面的代码哪里写错啦。

所以我们的目标就是生成 1155x944 的渐变图,然后再这个大一些的渐变图里圈出来 1000x600 的 你会发现 1000x600 恰巧是严丝合缝、一点都不差的。

最终裁剪图片

left top 是起点,width 和 height 是像素,这个起点就很好计算了,就是:(黑边图片宽 - 用户图片宽)/2; 高同理

left := (img.Width() - width) / 2
top := (img.Height() - height) / 2

_ = img.ExtractArea(left, top, width, height)

最终的任意角度渐变代码

需要注意上面提过,CSS 180deg 是默认的(to bottom) 所以我们这里弄一个 “默认参数” 然后对 angel 进行归零调整,这样 CSS 里用 30deg 就和我们传参数 30 一样啦。

另外,这里我投机取巧了一下,不用三角函数计算了,反正计算出来的结果就是 Similarity 的数字,直接生成一个假的图片,然后取宽高就好

func generateGradient(width int, height int, startColor []float64, endColor []float64, angle ...float64) *vips.ImageRef {
	bgColor := vips.ColorRGBA{R: 0, G: 0, B: 0, A: 255}
	// 180 是正方向,也是默认值
	var cssAngle float64
	if len(angle) > 0 {
		cssAngle = angle[0] - 180
	} else {
		cssAngle = 0
	}
	fakeImg := newImage(width, height)
	_ = fakeImg.Similarity(1.0, cssAngle, &bgColor, 0, 0, 0, 0)
	newWidth := fakeImg.Metadata().Width
	newHeight := fakeImg.Metadata().Height
	fakeImg.Close()

	img := newImage(1, newHeight)
	for i := 0; i < newHeight; i++ {
		ratio := float64(i) / float64(newHeight-1)
		r := uint8(startColor[0]*(1-ratio) + endColor[0]*ratio)
		g := uint8(startColor[1]*(1-ratio) + endColor[1]*ratio)
		b := uint8(startColor[2]*(1-ratio) + endColor[2]*ratio)
		_ = img.DrawRect(vips.ColorRGBA{R: r, G: g, B: b, A: 255}, 0, i, 1, 1, true)
	}
	_ = img.Replicate(newWidth, 1)

	_ = img.Similarity(1.0, cssAngle, &bgColor, 0, 0, 0, 0)

	// Crop the image to the desired size
	left := (img.Width() - width) / 2
	top := (img.Height() - height) / 2
	_ = img.ExtractArea(left, top, width, height)

	return img
}

如下方式调用

generateGradient(1000, 600, []float64{255, 0, 0}, []float64{0, 0, 255})
generateGradient(1000, 600, []float64{255, 0, 0}, []float64{0, 0, 255}, 23)

最终对比效果图,以 23 度为例

radial gradient

径向渐变,像辐射一样的渐变效果,radial-gradient (red, blue); 意味着中间是红色,周边是蓝色,用如下代码可以生成

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Title</title>
  </head>
  <body class="gradient">
    <div style="width: 500px; height: 500px" class="simple-linear"></div>
  </body>
  <style>
    .simple-linear {
      background: radial-gradient (red, blue);
    }
  </style>
</html>

线性渐变的时候,我们是看当前像素的比例,然后计算不同的 RGB 值。

radial-gradient 的时候,我们的计算方法要发生改变,图像中心(对角线的交点)是 100% 红色,外围是蓝色。

简单起见,我们只比较最长距离就好了(显示效果和浏览器渲染效果略有区别)。

因此,我们要做的计算有两个:

  • 当前点到图像中心的距离
  • 最长距离

这是数学中求两点距离的问题,如图所示

我们需要求出红线的长度,对角线一半的长度,然后计算比例,即可知道红色应该占比多少,蓝色应该占比多少

两点间距离的计算方法同样应用了勾股定理,其公式为

√ ((x₁-x₂)²+(y₁-y₂)² )

假设图片的宽高为 x y,那么中心点的坐标也就是 (x/2, y/2),最长的距离是对角线的一半,也就是 √ ((x/2)² + (y/2)²)

对于某像素,比如说 (a,b) ,到中心的距离为 √((a-x/2)² + (b-y/2)² )

用 Go 来表达以上数学概念,计算颜色的代码和线性一样:

startColor := []float64{255, 0, 0}
endColor := []float64{0, 0, 255}

width := 100
height := 60

img := newImage(width, height)
centerX := width / 2
centerY := height / 2
maxDistance := math.Sqrt(float64(centerX*centerX) + float64(centerY*centerY))

for x := 0; x < width; x++ {
	for y := 0; y < height; y++ {
		dx := float64(x - centerX)
		dy := float64(y - centerY)
		distance := math.Sqrt(dx*dx + dy*dy)
		ratio := distance / maxDistance

		r := uint8(startColor[0]*(1-ratio) + endColor[0]*ratio)
		g := uint8(startColor[1]*(1-ratio) + endColor[1]*ratio)
		b := uint8(startColor[2]*(1-ratio) + endColor[2]*ratio)

		_ = img.DrawRect(vips.ColorRGBA{R: r, G: g, B: b, A: 255}, x, y, 1, 1, true)

	}
}
imageBytes, _, _ := img.ExportJpeg(ep)
_ = os.WriteFile("radial-example.jpg", imageBytes, 0644)

效果如下:

优化 radial gradient 的性能

一个优化的办法是,渐变图像的其实变化很有规律的,如果能够通过算法接近无损放大图片,那么是不是就可以了!

没错的,著名的 PhotoZoom Pro 中提供了很多缩放算法,libvips 也包含了一些。包括如下几种

const (
	KernelAuto     Kernel = -1
	KernelNearest  Kernel = C.VIPS_KERNEL_NEAREST
	KernelLinear   Kernel = C.VIPS_KERNEL_LINEAR
	KernelCubic    Kernel = C.VIPS_KERNEL_CUBIC
	KernelLanczos2 Kernel = C.VIPS_KERNEL_LANCZOS2
	KernelLanczos3 Kernel = C.VIPS_KERNEL_LANCZOS3
	KernelMitchell Kernel = C.VIPS_KERNEL_MITCHELL
)
  1. KernelAuto: 这是一个特殊的值,表示自动选择一个合适的核。具体选取哪个核,取决于算法的实现和应用的上下文。
  2. KernelNearest: 最近邻插值算法。这是最简单的插值方法,它直接将最近的像素值分配给新的像素。这通常会导致放大的图像有锯齿效应,但它是所有方法中计算速度最快的。
  3. KernelLinear: 线性插值算法。它使用附近的2x2的像素来计算新像素的值。具体计算方式是对附近的像素进行加权平均。
  4. KernelCubic: 三次插值算法。它使用附近的4x4的像素来计算新像素的值。具体计算方式是对附近的像素进行加权平均,但权重是根据距离计算的三次多项式。
  5. KernelLanczos2: Lanczos插值算法,使用2个像素。这是一种更高质量的重采样方法,它使用正弦函数的加权平均值。
  6. KernelLanczos3: Lanczos插值算法,使用3个像素。这是Lanczos2的一个变体,它使用更多的像素来计算加权平均值,通常可以得到更好的结果,但计算速度会稍慢一些。
  7. KernelMitchell: Mitchell插值算法。这是一种比Lanczos更复杂的插值方法,它使用一种特殊的加权函数来计算加权平均值。

其中 Lanczos2 和 Lanczos3 以及 CUBIC 比较适合放大图片。

通常来说,最近邻插值最适用于速度是最重要的情况,而Lanczos和Mitchell适用于需要更高质量的图像放大或缩小。而KernelAuto是一个通用的选项,它会根据具体情况自动选择一个合适的插值算法。

其中 Lanczos2和Lanczos3 以及 CUBIC比较适合放大图片。

img.Resize (10, vips.KernelLanczos3)

更进一步,用户如果要求 1000x300 的图片,那么我们就给生成 100x30 的图片,然后放大 10 倍。相信这一定难不倒你!

下面是放大了之后的图片,感觉效果也还行。虽然没有浏览器那么完美,但是一般用起来也可以了!

总结

圆锥渐变 conic-gradient 我们暂时还没有时间去实现,但是和上述思想大体上没什么差别。

以后可要好好读书,就像几十年前我怎么会想到,会把几何知识应用到实际编程中呢?


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

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


Discuss on Hacker News