WebP Cloud Services Blog

How to achieve a gradient effect similar to CSS using govips

· Nova Kwok

这篇文章有简体中文版本,在: 如何使用 govips 实现类似 CSS 中的渐变效果

Last time, in the article “How do browsers implement CSS Filters? And how to achieve the same filter effects using govips”, we discussed how to implement partial CSS filters. Now, can gradients, which are also a CSS feature, be similarly supported? Of course, the answer is yes.

CSS provides great flexibility for gradients. To simplify the problem, this time we will only implement two basic types of gradients, which should be sufficient for most use cases.

To understand this article, you might need some basic mathematical knowledge, as well as an understanding of geometry, the Pythagorean theorem, and trigonometric functions.

Classification of CSS Gradients

CSS gradients mainly come in three types: linear-gradient, radial-gradient, and conic-gradient.

radial-gradient

and conic-gradient

The images above are from MDN.

Linear Gradient

linear-gradient in CSS supports gradients with multiple colors and allows users to define where the gradient starts and specify the gradient angle.

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

For simplicity, let’s start with a basic horizontal gradient from left to right, using only two colors.

Imagine a gradient from left to right, transitioning from red on the left to blue on the right. The closer you get to the left, the more red it becomes; the closer to the right, the more blue. The midpoint represents an equal mix of red and blue, which in RGB notation is (127, 0, 127).

Let’s consider a 5x1 image, a simple strip. We can easily conclude the following:

  1. On the far left, we use 1 to represent it. It is pure red, represented as (255, 0, 0).
  2. Slightly to the right, red is more dominant and blue is less dominant. Red is 75%, and blue is 25%, which is (191, 0, 63).
  3. Red and blue are balanced in the middle, which is (127, 0, 127).
  4. Blue is slightly more dominant, the reverse of 2, which is (63, 0, 191).
  5. All blue, no red, is (0, 0, 255).

Do you see the pattern here? It’s just a simple loop! If the width is 5, the algorithm can be expressed as follows:

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

Note that we use width - 1 because we want ratios like 0, 1/4, 2/4, 3/4, 1, not 0, 1/5, 2/5, 3/5, 4/5.

So, for RGB colors, we can calculate them using this ratio, multiplying each pixel by this 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)

How to Create Images

libvips provides many methods for creating images. However, govips has relatively few encapsulations, with only XYZ and Black available. Here, we’ll use Black to create an image, as it’s simple and easy to use:

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

It’s important to note that we need to convert the color space to RGB for it to be a color image.

How to Fill Pixels

Once you have a specific pixel value, how do you write that value to an image? govips provides a DrawRect method, which, as the name suggests, draws a rectangle. Here’s its prototype:

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

You provide the color, pixel position, rectangle size, and whether to fill it. For example, if you want to draw a filled square starting from the top-left corner with a size of 10x10, you can use:

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

To fill a single pixel, you can do:

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

Creating Gradient Images

We’ve already overcome various technical challenges, so now we just need to combine the code:

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()
    imageBytes, _, _ := img.ExportJpeg(ep)
    _ = os.WriteFile("gradient.jpg", imageBytes, 0644)
}

The result looks great!

However, you eagerly decided to increase the image’s width and height to 1000x1000. The result… you waited for 10 minutes, and there was still no output.

It’s indeed a challenge for us programmers because with a 1000x1000 image, you have to loop a million times, resulting in a time complexity of O(n²), making it unavoidably slow.

Optimizing Linear Gradient Performance

Is there a way to optimize performance? Looking at our gradient image, when viewed from left to right, the only difference between 1000x1 and 1000x1000 is that the 1000x1000 version repeats the 1000x1 stripe 1000 times, forming a rectangle.

So, can we generate a 1000x1 stripe and then duplicate it 1000 times to create a rectangle? This would reduce the loop from 1 million times to just 1000 times, resulting in a performance improvement of not just 1000 times, but at least 100 times.

Fortunately, libvips/govips provides a method called Replicate, which, as the name suggests, duplicates an image N times.

Replicate(across int, down int)

Here, across represents horizontal duplication, and down represents vertical duplication. Think of it as assembling a jigsaw puzzle! Let’s illustrate it with small squares:

Replicate(5,1)

◼︎◼︎◼︎◼︎◼︎

Replicate(1,5)

◼︎

◼︎

◼︎

◼︎

◼︎

So, we just need to use Replicate(1, height). However, it’s important to generate a 100x1 image instead of a 100x100 one!

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()
imageBytes, _, _ := img.ExportJpeg(ep)
_ = os.WriteFile("gradient.jpg", imageBytes, 0644)

On an M1 Pro Mac, calculating it a million times took 131 seconds, while replicating took only 1.31 seconds, resulting in a performance improvement of more than 100 times.

Comparing the Effect Images

You can generate a gradient in a straightforward manner using the following 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>

Comparison

Comparing the images, they are essentially the same.

Gradient Angles

CSS gradients support custom angles. For example, the to right angle in CSS represents a gradient from left to right, transitioning from red to blue. The default is to bottom, which is equivalent to 180deg, and 360deg would be red at the bottom and blue at the top.

An angle like 30deg is a custom angle. How can we achieve gradients at arbitrary angles?

For gradients that are multiples of 90 degrees, it’s straightforward. You can choose whether the gradient starts from the top, bottom, left, or right by adjusting the width or height and replicating horizontally or vertically.

However, for arbitrary angles, the calculation becomes a bit tricky.

How about a workaround? Can we start with a simple gradient, such as left to right, and then rotate it to any angle we want? This not only avoids the need to learn complex mathematical concepts but also eliminates the need for multiple calculations.

Of course, this algorithm is not very efficient. It’s far from the way browsers implement gradients.

Rotating an Image at Any Angle

You can achieve this by using the Similarity function in govips, with the following signature:

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

Here’s an example of rotating a 200x200 image by 23 degrees (specifically chosen as a non-multiple of 30, 45, or 90 degrees) with a black background color:

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

The resulting image will look like this. Can you identify the 23° angle?

Example Image

It appears that the angle is correct! Now, the remaining issues are:

  1. The gradient image needs to be slightly larger (but not too large). Users expect the generated image to be 200x200, but the actual resolution is 213x213 with black borders.
  2. Cropping the central 200x200 portion.

How to Calculate a Larger Gradient Image Size

You might be thinking:

  • Users want to generate a 200x200 gradient image, so why not create a larger image, say, twice as big, and then crop the central 200x200 portion? This approach is not feasible because it not only consumes a lot of resources to generate the larger image but also yields incorrect results. Imagine a user requests a 10x10 gradient, and you generate a 5000x5000 image and crop the central 10x10 portion; it would mostly be purple!
  • How about generating an image that is twice as large and, following the original image’s aspect ratio, selecting a rectangle within the colored region and scaling it back to 200x200? This idea is better and yields the correct results, but it is computationally expensive.

So, let’s optimize these ideas further. The best strategy would be to generate an image that is neither too big nor too small and then select the 200x200 portion in the center. But how do we calculate “not too big, not too small”?

Roughly, we can apply the Pythagorean theorem: sqrt(width*width + height*height). This gives a decent approximation.

For a more precise calculation, you’ll need some basic trigonometry knowledge. The problem can be simplified to the following math question:

Math Problem

Given that the values of x and y are 1000 and 600 respectively, and the angle is 23°, how do you calculate the length and height of the black rectangle? In other words, we need to find the values for abcd, where the length is d+a, and the height is b+c.

Let’s review some trigonometric functions, taking sine, cosine, and tangent as examples:

Trigonometry

  • The sine of θ is the ratio of the opposite side to the hypotenuse: sin θ = a/h
  • The cosine of θ is the ratio of the adjacent side to the hypotenuse: cos θ = b/h
  • The tangent of θ is the ratio of the opposite side to the adjacent side: tan θ = a/b

In other words:

  • Height: c+b = x * cos (90-23) + y * cos 23
  • Width: d+a = x * sin (90-23) + y * sin 23

The calculation code is as follows, taking into account the difference between degrees and radians. To avoid precision issues, we use math.Ceil to round up the values:

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)

The output result is: new width 1155.00, new height: 944.00

More Info

Indeed, the calculated result matches the resolution of the example image closely. If it doesn’t match, there may be an issue in the code above.

So, our goal is to generate a 1155x944 gradient image and then crop the 1000x600 portion from it. You’ll find that 1000x600 fits perfectly inside it.

Crop Inside

Final Image Cropping

The starting point for cropping is easy to calculate: (Width of the black-bordered image - Width of the user’s image) / 2 for both left and top.

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

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

Final Arbitrary Angle Gradient Code

As mentioned earlier, CSS 180deg is the default (equivalent to to bottom). Therefore, here, we create a “default parameter” and adjust the angle to zero. This way, when using 30deg in CSS, it’s equivalent to passing 30 as a parameter.

Additionally, we take a shortcut here and avoid using trigonometric calculations. The calculated result is the same as the number for Similarity. We generate a fake image, extract its width and height, and use those values.

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 is the correct direction and the default value
	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
}

You can use it as follows:

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

For a final comparison, here’s the result for a 23-degree angle:

Comparison

Radial Gradient

A radial gradient produces a gradient effect that radiates outwards, with the radial-gradient (red, blue); syntax meaning that the center is red, and it transitions to blue towards the outer edges. You can create it with the following HTML and CSS code:

<!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>

While linear gradients are based on the pixel’s relative position, for radial-gradient, we need to change our approach. In this case, the image center (intersection of diagonals) is 100% red, and the outer edges are blue.

For simplicity, we’ll consider only the longest distance (the actual rendering might have slight differences). Therefore, there are two calculations we need to perform:

  • The distance from the current point to the image center.
  • The longest distance.

This involves calculating the distance between two points, as shown in the mathematical diagram:

Math Problem

We need to find the length of the red line (half the length of the diagonal), and then calculate the ratio to determine the percentage of red and blue.

The distance between two points can be calculated using the Pythagorean theorem:

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

Assuming the image has dimensions x and y, the center point’s coordinates are (x/2, y/2), and the longest distance is half of the diagonal’s length, which is √ ((x/2)² + (y/2)²).

For a given pixel at (a, b), the distance to the center is √((a-x/2)² + (b-y/2)² ).

In Go, we can express these mathematical concepts, and the color calculation is similar to the linear gradient:

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)

The result looks like this:

Result

Optimizing Radial Gradient Performance

A way to optimize it is to notice that the gradient image changes in a very structured manner. If we could approximate an upscale of the image without loss, it would be ideal!

Yes, indeed! Well-known software like PhotoZoom Pro offers various scaling algorithms, and libvips includes some as well. These include:

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: This is a special value that indicates automatic selection of a suitable kernel. The specific kernel chosen depends on the implementation of the algorithm and the context of the application.
  2. KernelNearest: Nearest-neighbor interpolation. This is the simplest interpolation method, assigning the value of the nearest pixel directly to the new pixel. It often results in aliasing artifacts but is the fastest method.
  3. KernelLinear: Bilinear interpolation. It uses the values of the surrounding 2x2 pixels to calculate the new pixel’s value through weighted averaging.
  4. KernelCubic: Bicubic interpolation. It uses the values of the surrounding 4x4 pixels to calculate the new pixel’s value through weighted averaging, with weights based on a cubic polynomial that depends on the distance.
  5. KernelLanczos2: Lanczos interpolation using 2 pixels. This is a higher-quality resampling method that uses a weighted average based on a sine function.
  6. KernelLanczos3: Lanczos interpolation using 3 pixels. It’s a variant of Lanczos2 that uses more pixels for weighted averaging, often resulting in better quality at the cost of slightly slower computation.
  7. KernelMitchell: Mitchell interpolation. This is a more complex interpolation method than Lanczos, using a special weighted function for averaging.

Lanczos2, Lanczos3, and Cubic are suitable for upscaling images.

Typically, nearest-neighbor interpolation is most suitable when speed is critical, while Lanczos and Mitchell

Conclusion

We haven’t had the time to implement the conic gradient (conic-gradient) yet, but its underlying principles are similar to those discussed above.

In the future, it’s essential to continue learning, just as I couldn’t have imagined decades ago how geometry knowledge could be applied to practical programming.


The WebP Cloud Services team is a small team of three individuals from Shanghai and Helsingborg. Since we are not funded and have no profit pressure, we remain committed to doing what we believe is right. We strive to do our best within the scope of our resources and capabilities. We also engage in various activities without affecting the services we provide to the public, and we continuously explore novel ideas in our products.

If you find this service interesting, feel free to log in to the WebP Cloud Dashboard to experience it. If you’re curious about other magical features it offers, take a look at our WebP Cloud Services Docs. We hope everyone enjoys using it!


Discuss on Hacker News