In this post I go over how I made/how to make a “color palette converter.” It takes the colors of an image and converts them to a color palette of your choice. I got this idea when I was making a tool that converts images into Discord messages using the square emojis:

:black_large_square: 
:blue_square: 
:brown_square: 
:green_square: 
:purple_square: 
:red_square: 
:white_large_square: 
:orange_square: 
:yellow_square:

However, Discord messages are limited by the fact that only 2000 characters can be sent in one message and messages appear in different widths on different devices; not everyone will see the emojis the same way. So, a few of my friends over on the Python Discord Server asked if I could make a new one, but instead of making discord messages, to convert every pixel of the image to the colors of the Discord emojis.

Challenge Accepted

Creating it

Imports

To create this, we’ll need to install a few packages:

  1. opencv-python
  2. numpy

opencv-python is used for image manipulation and numpy can calculate math operations very efficently.

Aside from the packagess we need to install above, we’ll also be using:

  1. argparse
  2. os

Now let’s import each of these package in our code:

import argparse
import os

import cv2 as cv
import numpy

Next, we can define the discord emoji colors.

# You can change any of these values and add more if you don't want to use the Discord colors.
colors_rgb = {
    "black": [49, 55, 61],
    "blue": [85, 172, 238],
    "brown": [193, 105, 79],
    "green": [120, 177, 89],
    "orange": [244, 144, 12],
    "purple": [244, 144, 12],
    "red": [221, 46, 68],
    "white": [230, 231, 232],
    "yellow": [253, 203, 88],
}

Looping through the pixels

Now that we’ve completed the first parts, we should get a solid understanding of how the code will actually work. The flow chart looks like this:

Flow Chart

We’ll move down the flowchart and implement each step one by one. First, we need a way to loop through every pixel. To do that, we’ll need an image object that we can work on. Let’s instantiate one with opencv:

bgr_img = cv.imread("some image")

We also need some way to specify what image to open. This is where argparse comes in, at the top of our code, lets create some command line arguments for specifying the image.

parser = argparse.ArgumentParser() # Create parser
parser.add_argument("image", help="the image to convert") # Add the argument
args = parser.parse_args() # Get the arguments passed

bgr_img = cv.imread(args.image) # Instantiate an opencv image object using the filename passed

Now the program can be run like using: python3 color_palette_converter.py <your_image>.

All of our code so far should look like this:

import argparse
import os

import cv2 as cv
import numpy


# Define colors
colors_rgb = {
    "black": [49, 55, 61],
    "blue": [85, 172, 238],
    "brown": [193, 105, 79],
    "green": [120, 177, 89],
    "orange": [244, 144, 12],
    "purple": [244, 144, 12],
    "red": [221, 46, 68],
    "white": [230, 231, 232],
    "yellow": [253, 203, 88],
}

# Create parser
parser = argparse.ArgumentParser()
parser.add_argument("image", help="the image to convert")
args = parser.parse_args()

# Read image
bgr_img = cv.imread(args.image)

You also may have noticed that I named the image bgr_img. This is because by defualt, when opencv opens an image, each pixel’s color value will be formatted with blue first, then green, and finally red (BGR). In contrast, we defined the colors that we want to convert to in RGB (red, green, blue), so we need some way to convert the bgr_img to RGB so that when we get the color values for each pixel, we can compare them easily to our predefined color palette which is in RGB.

opencv makes this easy too:

rgb_img = cv.cvtColor(bgr_img, cv.COLOR_BGR2RGB)

Great, now we can succesfully read the image and we just need to loop through it. We can do so like this:

height, width = rgb_img.shape[:2]

for row in range(height):
    for column in range(width):
        print(rgb_img[row, column])

The rgb_img.shape tuple holds the information about the height and width of the image. The first value is height, the second is width. There is one more third value, but it’s irrelevant to us, so we can unpack the lists first two values by adding [:2].

We can get the color value of a pixel by indexing the image with rgb_img[row, colum]. Here this value will be stored in RGB format because we converted the image from BGR to the much more common RGB format.

Our code should now look like this:

import argparse
import os

import cv2 as cv
import numpy


# Define colors
colors_rgb = {
    "black": [49, 55, 61],
    "blue": [85, 172, 238],
    "brown": [193, 105, 79],
    "green": [120, 177, 89],
    "orange": [244, 144, 12],
    "purple": [244, 144, 12],
    "red": [221, 46, 68],
    "white": [230, 231, 232],
    "yellow": [253, 203, 88],
}

# Create parser
parser = argparse.ArgumentParser()
parser.add_argument("image", help="the image to convert")
args = parser.parse_args()

# Read image
bgr_img = cv.imread(args.image)
rgb_img = cv.cvtColor(bgr_img, cv.COLOR_BGR2RGB)

# Loop through pixels
height, width = rgb_img.shape[:2]

for row in range(height):
    for column in range(width):
        print(rgb_img[row, column])

Finding the nearest color

To find the nearest color for each pixel we are going to use a modified euclidean distance function. The euclidean distance is the distance between two points. In our case, two colors or two 3D points as they have three values.

The euclidean distance formula looks like this:

Euclidean Distance

In our case, x represents the red value, y represents the green value, and z represents the blue value.

Notice in the formula a square root is used. We don’t need to square root it because the smallest value of the squares will result in the smallest value when square rooted. So we can make it more efficent if we leave out the square root.

Our new equation looks like this:

Euclidean Distance

Lets implement this distance function:

def distance(p1, p2):
    """Return a modified euclidean distance value between two points."""
    difference = numpy.subtract(p1, p2) # Subtract each corresponding value
    squared = numpy.square(difference) # Square each corresponding value
    distance = numpy.sum(squared) # Sum each corresponding value
    return distance

This function takes two points and subtracts each corresponding value to make a new list. It then squares each value in that new list. Finally, it sums the values of the new list into a final distance value which we return.

Now all we need to do is use this distance function to find the smallest distance between the RGB values of the pixel and our defined color palette. Whichever has the smallest distance is the closest value and we can then set the color of the pixel to that closest color.

Let’s add a function to calculate the color of smallest difference between an input color value. This input will be the color of the pixel, enventually.

def nearest_color(rgb):
    """Return the RGB value of the color with the least distance away from the input color."""
    return min(colors_rgb.values(), key=lambda c: distance(rgb, c))

This just finds the minimum value of the distance between the input color and all of our predifened values. When it finds the minimum, the return value will be the value of the color of least distance because we are checking the color against colors_rgb.values().

Now all we have to do is use these functions to find the color of least distance from the current pixel we are looping through and set the color value of that pixel to the one returned:

rgb_img[row, column] = nearest_color(rgb_img[row, column])

Put that code in the loop.

All of the code together now should look like this:

import argparse
import os

import cv2 as cv
import numpy


def distance(p1, p2):
    """Return a modified euclidean distance value between two points."""
    difference = numpy.subtract(p1, p2)
    squared = numpy.square(difference)
    distance = numpy.sum(squared)
    return distance

def nearest_color(rgb):
    """Return the RGB value of the color with the least distance away from the input color."""
    return min(colors_rgb.values(), key=lambda c: distance(rgb, c))


# Define colors
colors_rgb = {
    "black": [49, 55, 61],
    "blue": [85, 172, 238],
    "brown": [193, 105, 79],
    "green": [120, 177, 89],
    "orange": [244, 144, 12],
    "purple": [244, 144, 12],
    "red": [221, 46, 68],
    "white": [230, 231, 232],
    "yellow": [253, 203, 88],
}

# Create parser
parser = argparse.ArgumentParser()
parser.add_argument("image", help="the image to convert")
args = parser.parse_args()

# Read image
bgr_img = cv.imread(args.image)
rgb_img = cv.cvtColor(bgr_img, cv.COLOR_BGR2RGB)

# Loop through pixels
height, width = rgb_img.shape[:2]

for row in range(height):
    for column in range(width):
        rgb_img[row, column] = nearest_color(rgb_img[row, column])

Saving the image

To save the image we can do the following:

filename, filetype = os.path.splitext(args.image)
cv.imwrite(f"{filename}_discord.{filetype}", rgb_img)

And now we are done! You can run the code like so, python3 color_palette_converter.py <your_image>

Extra

If you’d like to see the full code you can look on my github page. There I’ve added some additional code for displaying the progress of the image as it can take a while for bigger images.