Generating good terminal colors

Published April 02, 2023 · 3 min read

The popular software pywal is a great way to quickly set your wallpaper and have your terminal reflect the wallpapers colors in its appearance. However it has a quite serious flaw: The generated colors do not match the semantic ones. In other words if your wallpaper is mostl blue, then the color the would be red in your color scheme most likely will appear blue, simply because it is the first color assigned a value. This can lead to certain terminal output or TUIs not being displayed correctly. Or rather: being viewed and understood correctly.

So I set out for a mission to generate some actually good colors for the terminal, given an arbitrary image as an input. This article will only just touch upon the basic ideas I have so far.

Abstraction

To start off, let's abstract the objective: The goal is to get the most prominent colors of an arbitrary image sorted by the ANSI terminal color which they are most closely related to.

To achieve this, we might want to first get the top n colors of an image. From there we would need to determine where in that color-space our the ideal ANSI terminal colors, s.v. anchors are, and which colors are closest to them. With higher n we would have better precision, yet higher computation time.

Throwing together a working example

For now, I will be using a library to do all the work. For performance or feature-oriented reasons this might change, but for now this will do:

def main(file, color_count):
    # NOTE: The arguments to this function were stolen from the library's internal code.
    colorz_tuple = clz.colorz(file, color_count, 170, 200, 50)
    top_colors = []

    # This just normalizes the colors from the colorz library so I can work with them better.
    for tup in colorz_tuple:
        color = tup[1]
        normalized_color = (color[0] / 256, color[1] / 256, color[2] / 256)
        top_colors.append(Color(rgb=normalized_color))

    most_color = top_colors[0]
    terminal_colors = {"r": 0, "g": 120, "y": 60, "b": 240, "m": 300, "c": 180}

Notice that I have a dictionary called terminal_colors. It contains each anchor and it's corresponding hue value in degrees in the https://en.wikipedia.org/wiki/HSL_and_HSV[HSL] color space.

// image:/assets/blog/hsl_cylinder.png[width=200px]

    # (color, key, hue_offset, distane) of color with minimal distance yet
    optimal_color = (Color("#ff0000"), "r", 0, 1)

    for key in terminal_colors:
        color, distance = select_nearest(top_colors, terminal_colors[key])

        if distance < optimal_color[3]:
            optimal_color = (color, key, terminal_colors[key], distance)

        if distance > max_distance:
            # set terminal_colors[key] to a better color

At the very end, I just forumalte a proper list of colors. This is just the ANSI colors in the previously used order. I add my own shades of gray though.

    palette = list(terminal_colors.values())
    palette.insert(0, "#1a1a1a") # my favorite constant black
    palette.append("#efefef") # my favorite constant white

To get the distance, I use the following function. Notice that this is just a weird way to calculate the norm of a delta vector of the RGB color space. This should also be optimized (and possible rethought) later.

I personally would like to use the HSL color space, since it should make calculations easier. And the conversion from RGB to HSL and vice versa introduces a distortion.

def select_nearest(colors, value):
    color = colors[0]
    target = Color(hsl=(value / 360, 1, 0.5)).rgb
    distance = get_distance(color.rgb, target)

    for c in colors:
        new_distance = get_distance(c.rgb, target)

        if new_distance  < distance:
            color = c
            distance = new_distance

    return color, distance

On the other hand, get_distance ist basically just Pythagoras' Theorem.

def get_distance(color, target):
    # TODO: Use hsl / hsv instead of rgb
    return math.sqrt((color[0] - target[0])**2 + (color[1] - target[1])**2 + (color[2] - target[2])**2)

Results

The results are somewhat underwhelming, I must admit. But this is most likely due to:

Once those are fixed, I believe to have quite an ineresting software on my hands. But for now this will do. Until then, this work is to be procrastinated! Unless I need it again...