Mesh Gradient Avatars in Rails

By Exequiel Rozas

Dynamic avatars with Ruby are a common feature as we usually want to provide sensible default values without resorting to the same default avatar for every single user.

The most common solution to this issue is to generate them using the user's initials, default images or background colors.

These solutions are fine but we can do better: in this article we will learn how to create mesh gradient avatars using Ruby and the ChunkyPNG gem to improve our avatar game

Let's start by understanding what mesh gradients are:

What are mesh gradients

It's a type of gradient that uses a set of control points, each with its color to create smooth color transitions.

Unlike linear gradients, which transition between colors in a straight line, or radial gradients, which radiate outward from a center point, mesh gradients blend colors across multiple points simultaneously.

This creates a smooth and flowing pattern that is visually pleasing and organic in nature.

Let's see how mesh gradients look:

Mesh gradient examples

As you can see, these look very smooth and vibrant by combining multiple colors in different fashions and making them blend naturally.

This happens partly because colors blend in a way that makes it difficult to pinpoint where they start or end which makes them look organic because it resembles scenes like sunsets over water for example.

Let's start by understanding how to generate images with Ruby:

Generating images with Ruby

Instead of jumping straight into mesh gradients, let's understand how we can generate images with Ruby using the ChunkyPNG gem which is a library that can read and write PNG files, supports encoding and decoding for all the color modes and has read and write access to the image's pixels.

An image can be defined as a finite and discrete function from a 2D grid of coordinates where each coordinate represents a single pixel that encodes a vector in a color space.

In other words, an image can be defined as an array of coordinates where each coordinate has an associated color value.

For example, here we have an upscaled 32×32 image with a finite set of pixels that have an associated RGBA color:

Upscaled pixels image

So, let's pick a color from this article's cover (138, 122, 216), and make a 128×128 image that contains that color exclusively:

require 'chunky_png'

width, height = [128, 128]
color = [138, 122, 216]
image = ChunkyPNG::Image.new(width, height, ChunkyPNG::Color::TRANSPARENT)

height.times do |y|
  width.times do |x|
    image[x, y] = ChunkyPNG::Color.rgb(color[0], color[1], color[2])
  end
end

image.save("solid-color.png")

After running this script and opening the solid-color.png image, we get the following result:

Solid color image in Ruby using ChunkyPNG

Of course, it's not much but it's a start: now we know that we can just iterate over a two-dimensional array and set the color of the pixel to our desired value using ChunkyPNG::Color.rgb or .rgba in case we want to use transparency.

With this in mind, let's think about how to generate a linear gradient between two points.

Generating a linear gradient

Now that we know how to assign colors to pixels, we need to figure out a way to produce a gradient and we need to start answering the question: what is a gradient?

A gradient is essentially a smooth transition between colors across a space that's produced by defining a given amount of colors that are increasingly close to each other.

The mathematical foundation for this is called linear interpolation which uses the following formula:

Result = A + t x (B - A)

Where t is a value between 0 and 1 that:

  • When t = 0 we get our starting color.
  • When t = 1 we get our end color.
  • When t = 0.5 we get a color that's exactly between A and B.

As we're using the RGB (Red Green Blue) color space, which uses 3 vectors with a number between 0 and 255 to represent every possible color in this color space.

So, to produce a gradient, we have to calculate the values for R, G, and B for each coordinate in our image.

For a simple example using pure red (255, 0, 0) as a starting color and pure white as a finishing color (255, 255, 255).

If we try with t = 0 for the R component, we should get (255, 0, 0) because it's the starting color:

Result(R) = 255 + 0 x (255 - 255) => 255
Result(G) = 0 + 0 x (255 - 0) => 0
Result(B) = 0 + 0 x (255 - 0) => 0

Now, if t = 1 meaning we reached the end of the gradient, we should get white:

Result(R) = 255 + 1 x (255 - 255) => 255
Result(G) = 0 + 1 x (255 - 0) => 255
Result(B) = 0 + 1 x (255 - 0) => 255

With this in mind, let's generate a top to bottom gradient with two colors from this article's cover:

require "chunky_png"

start_color = [134, 119, 235]
end_color = [87, 48, 161]
width, height = [128, 128]

image = ChunkyPNG::Image.new(width, height)

height.times do |y|
  # Calculate the interpolation factor (0.0 at top, 1.0 at bottom)
  progress = y.to_f / (height - 1)

  # Interpolate each color channel (Red, Green, Blue) separately
  red = (start_color[0] + (end_color[0] - start_color[0]) * progress).round
  green = (start_color[1] + (end_color[1] - start_color[1]) * progress).round
  blue = (start_color[2] + (end_color[2] - start_color[2]) * progress).round

  color = ChunkyPNG::Color.rgb(red, green, blue)

  width.times do |x|
    image[x, y] = color
  end
end

If you're wondering what the progress variable does, it interpolates the height value regarding the current y coordinate to produce a t value that starts at 0 and progressively increases to reach 1.

This produces a top to bottom gradient that looks like this:

Gradient image produced with ChunkyPNG in Ruby

If we wanted to produce a bottom to top gradient, we just have to invert the progress factor to start at the bottom of the image:

progress = (height - 1 - y).to_f / (height - 1)

This produces the inverse gradient:

Bottom to top gradient in Ruby

Angled gradient

Finally, let's make an angled gradient with a 45° angle. To make this happen, we have to convert the angle into radians, then calculate the direction vector for the angle, calculate the distance along the gradient and then use the progress to produce an interpolation between 0 and 1 to produce the desired color for each pixel:

require "chunky_png"

start_color = [134, 119, 235]
end_color = [66, 20, 114]
width, height = [128, 128]

angle = 45
angle_radians = angle * Math::PI / 180

# Direction vector
dx = Math.cos(angle_radians)
dy = Math.sin(angle_radians)

# Calculates the length of the gradient across the image
# as we're using a 45 degree angle, it's the hypothenuse
gradient_length = (width * dx.abs + height * dy.abs)

image = ChunkyPNG::Image.new(width, height)

height.times do |y|
  width.times do |x|
    # We get the distance along the gradient and then
    # interpolate it to a value between 0 and 1
    distance_along_gradient = x * dx + y * dy
    progress = distance_along_gradient / gradient_length

    red = (start_color[0] + (end_color[0] - start_color[0]) * progress).round
    green = (start_color[1] + (end_color[1] - start_color[1]) * progress).round
    blue = (start_color[2] + (end_color[2] - start_color[2]) * progress).round

    color = ChunkyPNG::Color.rgb(red, green, blue)
    image[x, y] = color
  end
end

image.save("angled-gradient.png")

Which produces the following result:

Angled gradient with Ruby

Now that we have a general idea of how to generate gradients, let's start with the process to generate our desired results:

Mesh gradients

We conceptually understand what mesh gradients are but producing them in Ruby is a different thing.

To produce convincing mesh gradients, there are basically four core components:

  • A color palette: we will add a hand-curated color palette to avoid producing color combinations that are not pleasant or that produce undesired color clashes.
  • Control points: these are like colored pins in our image. Each one of them has an associated position and a color that we will use to further interpolate to produce a nice-looking gradient.
  • Interpolation: it's the math that calculates what color each pixel should be based on nearby control points.

Let's start this process by defining a set number of color palettes and adding a hex_to_rgba method to produce our desired results:

Color palettes

Automatic color palette generation is possible using color theory rules. They can automatically produce some nice color palettes while also producing unpleasant combinations.

To solve this, let's start this process by creating our color palettes in a software like Figma.

After this process, we may end up with something like this:

Color palettes

I'm selecting 4 colors for each palette with different strategies just to see what turns out when generating the gradients.

However, this process is subjective so you might want to experiment with the color combinations and see what works best for you.

After we have the colors, we will save them in a colors.yml file to keep the color management separate.

Color clustering

For our first method, we will use a color clustering approach where we select 3 to 4 color clusters based on a grid where every cluster has a center_x, center_y and radius properties.

These clusters do not directly affect the image rendering but we use them to make sure that we have a good distribution of the control points across the canvas.

This looks something like this:

Mesh gradient with control points and influence radius

Let's start building this by creating a MeshGradientGenerator class and initializing it by loading the colors we stored in the colors.yml file:

require 'chunky_png'
require 'yaml'

class MeshGradientGenerator
  attr_reader :size, :color_data

  def initialize(size)
    @size = size || 128
    @color_data = load_color_palettes
  end

  private

  def load_color_palettes
    file_path = File.join(File.dirname(__FILE__), 'colors.yml')
    YAML.load_file(file_path)
  end
end

Next, let's add a generate method which will return an instance of ChunkyPNG::Image with the correct pixels calculated based in the colors of our palette.

Let's start by picking a random color and initializing an empty image:

def generate(size)
  colors = color_data["palettes"].sample
  image = ChunkyPNG::Image.new(size, size, ChunkyPNG::Color::TRANSPARENT)
  set_pixels(image, colors)
end

Now, with this in place, let's start adding the generation functionality. The first step is to select the clusters by starting with a 2×2 grid and placing 3 to 4 clusters randomly in grid cells:

clusters = []
grid_size = 2
cell_size = size / grid_size # If our image is 256x256, the cell size is 128
num_clusters = 3 + (Random.rand * 2).floor # Defines the number of clusters to 3 or 4

available_positions = (0...grid_size).to_a.product((0...grid_size).to_a) # An array of available grid positions

For example, for a grid size of 2, the available_positions returns the following array:

[[0, 0], [0, 1], [1, 0], [1, 1]]

Which represent the top left, bottom left, top right and bottom right rows.

Then, we select the positions for our clusters by sampling from this array:

selected_positions = available_positions.sample(num_clusters)

For example, if we have 3 clusters, the selected_positions may return an array that looks like this:

[[1, 0], [1, 1], [0, 1]]

Meaning the clusters will be positioned in the top right, bottom right and bottom left rows.

The coordinate system starts at (0,0) in the top-left corner, with X increasing rightward and Y increasing downward.

The next step, is to convert this into actual pixel positions in the image:

selected_positions.each do |grid_x, grid_y|
  center_x = grid_x * cell_size + cell_size * (0.25 + Random.rand * 0.5)
  center_y = grid_y * cell_size + cell_size * (0.25 + Random.rand * 0.5)

  clusters << {
    center_x: center_x,
    center_y: center_y,
    radius: size * (0.2 + Random.rand * 0.1) # 20-30% radius of influence
  }
end

This populates the clusters array with hashes that look like this:

[{center_x: 215.40895602245428, center_y: 38.598171736451945, radius: 51.72047548558696},
 {center_x: 177.28426579501428, center_y: 192.72302748273432, radius: 58.384272136566565},
 {center_x: 72.54883269587445, center_y: 198.3578506255008, radius: 65.17937303653494}]

As the pixel position is calculated from (0, 0) with X increasing to the right and Y increasing to the bottom, the first cluster is located in the top right, the second cluster is located at the top bottom right and the third is located at the bottom left.

With this in place, now it's time to define the control points which are basically where we place the individual colors from our palette.

We start by defining an empty control_points array and defining the number of points:

control_points = []
num_points = 3 + (Random.rand * 3).floor # We set 3 to 5 points

Then, we run a loop for each point and assign a control point that has an x and y value and an associated color:

num_clusters.times do |i|
  cluster = clusters[i % clusters.length] # It adds at least one point to each cluster

  angle = Random.rand * 2 * Math::PI # 0 to 360 degrees in radians
  distance = cluster[:radius] * (0.5 + Random.rand * 0.5) # Uses between 50-100% of the selected radius

  x = cluster[:center_x] + Math.cos(angle) * distance
  y = cluster[:center_y] + Math.sin(angle) * distance

  # Clamping the values to make sure they don't exceed borders
  margin = size * 0.05 # 5% of margin
  x = [[margin, x].max, size - margin].min
  y = [[margin, y].max, size - margin].min

  control_points << {x: x, y: y, color: colors[i % colors.length]}
end

Here, we're adding control points within our clusters using the radius and a random angle to calculate the distance and make sure we stay within a margin by clamping the values.

The next and final step is to iterate over each pixel and calculate the color that each pixel should have by calculating the distance of the current pixel to each control point and then weighting the distance using the Gaussian weight formula.

The Gaussian weight formula creates a bell curve where nearby pixels have exponentially stronger influence than distant ones. With a sigma or influence factor of 0.28 (28% of the image) control points significantly affect pixels within a ~72px radius, while influence drops to near zero beyond 144px.

Then, for each one of the control points we produced a weighted RGB value by adding the individual weights for every point.

Pixel color calculation based on distance to control point

Finally, we calculate the final pixel color by dividing the weighted color value by the weight value we calculated using the Gaussian formula.

INFLUENCE_FACTOR = 0.28

size.times do |y| # 256 rows
  size.times do |x| # 256 columns per row
    total_weight = 0
    # We start with a black pixel
    weighted_red = weighted_green = weighted_blue = 0

    # We iterate over each control point and calculate the color for a given pixel
    # taking each one of the points into consideration. Think about it as mixing
    # the 3 colors considering the distance of the pixel to each one of them.
    control_points.each do |point|
      # We calculate the distance from the current pixel to every control point using the Euclidean formula.
      # In other words: we calculate how far each is pixel is from every control point.
      distance = Math.sqrt((x - point[:x])**2 + (y - point[:y])**2)


      # We use the Gaussian weight formula which assigns higher weights to closer pixels
      # while distant pixels get lower weights. The sigma value controls how the influence
      # falls off. 
      sigma = size * INFLUENCE_FACTOR
      weight = Math.exp(-(distance ** 2) / (2 * sigma ** 2))

      total_weight += weight
      weighted_red += point[:color][0] * weight
      weighted_green += point[:color][1] * weight
      weighted_blue += point[:color][2] * weight
    end

    total_weight = [total_weight, 0.01].max # We clamp the total_weight factor

    # We clamp the values to make sure they're betwen 0 and 255
    final_color = [
      [[0, (weighted_red / total_weight).round].max, 255].min,
      [[0, (weighted_green / total_weight).round].max, 255].min,
      [[0, (weighted_blue / total_weight).round].max, 255].min,
    ]

    # We setup the color value for the current pixel
    image[x, y] = ChunkyPNG::Color.rgb(final_color[0], final_color[1], final_color[2])
  end
end

If you need to try this, the final shape of the code is:

require 'chunky_png'
require 'yaml'

class MeshGradientGenerator
  include Utils
  include Blob

  attr_reader :size, :color_data

  def initialize(size)
    @size = size || 128
    @color_data = load_color_palettes
  end

  def generate
    colors = color_data["palettes"].sample["colors"]
    image = ChunkyPNG::Image.new(size, size, ChunkyPNG::Color::TRANSPARENT)

    set_pixels(image, colors)
    image
  end

  def set_pixels(image, colors)
    clusters = []
    grid_size = 2
    cell_size = size / grid_size
    num_clusters = 3 + (Random.rand * 2).floor

    available_positions = (0...grid_size).to_a.product((0...grid_size).to_a)
    selected_positions = available_positions.sample(num_clusters)

    selected_positions.each do |grid_x, grid_y|
      center_x = grid_x * cell_size + cell_size * (0.25 + Random.rand * 0.5)
      center_y = grid_y * cell_size + cell_size * (0.25 + Random.rand * 0.5)

      clusters << {
        center_x: center_x,
        center_y: center_y,
        radius: size * (0.20 + Random.rand * 0.15)
      }
    end

    control_points = []
    num_points = 4 + (Random.rand * 3).floor

    num_clusters.times do |i|
      cluster = clusters[i % clusters.length]

      angle = Random.rand * 2 * Math::PI
      distance = cluster[:radius] * (0.5 + Random.rand * 0.5)

      x = cluster[:center_x] + Math.cos(angle) * distance
      y = cluster[:center_y] + Math.sin(angle) * distance

      margin = size * 0.05
      x = [[margin, x].max, size - margin].min
      y = [[margin, y].max, size - margin].min

      control_points << {
        x: x,
        y: y,
        color: colors[i]
      }
    end

    size.times do |y|
      size.times do |x|
        total_weight = 0
        weighted_r = weighted_g = weighted_b = 0
        INFLUENCE_FACTOR = 0.28

        control_points.each do |point|
          distance = Math.sqrt((x - point[:x])**2 + (y - point[:y])**2)

          sigma = size * INFLUENCE_FACTOR
          weight = Math.exp(-(distance**2) / (2 * sigma**2))

          total_weight += weight
          weighted_r += point[:color][0] * weight
          weighted_g += point[:color][1] * weight
          weighted_b += point[:color][2] * weight
        end

        total_weight = [total_weight, 0.01].max

        final_color = [
          [[0, (weighted_r / total_weight).round].max, 255].min,
          [[0, (weighted_g / total_weight).round].max, 255].min,
          [[0, (weighted_b / total_weight).round].max, 255].min
        ]

        image[x, y] = ChunkyPNG::Color.rgb(final_color[0], final_color[1], final_color[2])
      end
    end
  end

  private

  def load_color_palettes
    file_path = File.join(File.dirname(__FILE__), 'colors.yml')
    YAML.load_file(file_path)
  end
end

Now, we can write a simple script to generate a given number of avatars and save them to disk:

require_relative 'mesh_gradient_generator'

size = 128
generator = MeshGradientGenerator.new(size)

30.times do |i|
  result = generator.generate
  filename = "output/example-#{(i + 1).to_s}.png"
  result.save(filename) # We call save on the ChunkyPNG::Image instance

  puts "#{i + 1} - #{filename}"
end

After running this script, we get the following result:

Mesh gradient avatars with Ruby results

After running a benchmark for this, the average time needed to generate a 128x128 avatar is 100 ms and for 256x256 is 350 ms.

Of course, Ruby is not really the language to pick if we have to squeeze performance out of tasks like image generation but using this approach to dynamically generate avatars, which are don't usually need to be too big, is acceptable.

Deterministic avatars in Rails

Now that we know how to generate credible mesh gradients using Ruby, let's create a Rails application that's in charge of generating deterministic avatars from an input.

This means that we produce the same avatar as long as the input is the same, which is very useful to generate dynamic avatars while also keeping consistency for our users.

Let's start by creating a new Rails application:

rails new avatars --css=tailwind

Next, let's add a route that is responsible for returning an avatar when called:

get "avatar/:id", to: "avatars#create"

The next step is to add the code for the MeshGradientGenerator and the colors.yml file in the lib directory and modify the load_color_palettes method with the following:

def load_color_palettes
  path = Rails.root.join("lib", "colors.yml")
  YAML.load_file(file_path)
end

Then, let's add the controller and action for this endpoint where we call the MeshGradientGenerator we created in the previous steps:

# app/controllers/avatars_controller.rb
class AvatarsController < ApplicationController
  def create
    avatar = MeshGradientGenerator.new(128).generate # Instance of ChunkyPNG::Image
    send_data avatar.to_blob, type: "image/png", disposition: inline
  end
end

With this in place, we get the following result:

However, as you can see, a different avatar is generated every time even though the id is the same.

To achieve truly deterministic avatars, we need to introduce the concept of a Linear Congruential Generator (LCG) which is a pseudorandom number generator that can generate the same sequence of random numbers starting from a seed.

As we use many Random.rand instances in our avatar generator, we will replace them with an rng lambda that returns the same value for each subsequent call as long as the seed is the same.

For example, if we have a seed of abcd we get the following values:

seed = 123456
rng = seeded_random(seed)
rng.call # First call =>  0.634674724952632
rng.call # Second call => 0.27528032580170797
rng.call # Third call => 0.02930058493712013
rng.call # Fourth call => 0.1504786518171796

Then, if we used the same seed, we would get the same values for each sequential call and we could use those values to pick the colors, control points and every thing that makes our avatar generation random.

Without getting too deep into the algorithm, we define the seeded_random method which returns a lambda that keeps track of the state using the seed we provide and produces different numbers for each call we make:

def seeded_random(seed)
  m = 0x80000000 # 2**31
  a = 1103515245
  c = 12345
  state = seed&.hash&.abs || Random.rand(m - 1)

  lambda do
    state = (a * state + c) % m
    state.to_f / (m - 1)
  end
end

Then, we replace every Random.rand call with a call to the rng variable just like in the following line:

num_clusters = 3 + (rng.call * 2).floor

Then, in the controller, we add the following:

class AvatarsController < ApplicationController
  def show
    seed = params[:id].to_s
    size = params[:size].to_i.clamp(128, 512)
    avatar = MeshGradientGenerator.new(size, seed).generate

    send_data avatar.to_blob, type: "image/png", disposition: "inline"
  end
end

This produces the following result: as long as the seed stays the same, the produced avatar is the same:

We now have a functioning application capable of generating mesh gradient avatars on the fly using nothing but Ruby.

There's still some work needed to launch this to production like adding rate limiting, caching with a CDN, and contemplating edge cases but this is a good starting point.

Summary

In this article, we learned how to create mesh gradient avatars using nothing but Ruby with the ChunkyPNG.

We started by learning how to generate a two-color gradient using interpolation.

Then, we approached some of the most common issues that can arise when generating avatars dynamically by using a color clustering approach where we divide the image using a grid, pick which part of the grid to add the clusters to and then add control points, a.k.a. the actual colors, which are used to calculate the color for every pixel.

After this, we calculate the color for each pixel by using the distance from each pixel to each control point (colors in our palette) and the Gaussian formula to make sure that the color increases its influence non-linearly as pixels get closer to it.

For example, if we have a blue, cyan and yellow palette and we want to calculate the color for a pixel that's near to the blue control point, the pixel should be influenced by the blue color way more than it's influenced by the yellow and cyan points.

We can use this basic approach to generate a greater variety of gradients by playing with the way we place the control points or we calculate the influence.

However, this approach produces good results with acceptable performance.

I hope you enjoyed this article and that it can help you with your projects. Let us know if you know about other ways to achieve similar results.

Have a good one and happy coding!


Build your next rails app 10x faster with Avo

Avo dashboard showcasing data visualizations through area charts, scatterplot, bar chart, pie charts, custom cards, and others.

Find out how Avo can help you build admin experiences with Rails faster, easier and better.