WOWSIGNAL.io

Naming the 256 XTerm colors

You can download this article as a Jupyter Notebook.

Why don’t XTerm colors have names? X11 colors have names, as do web, SVG and lipstick colors. There must be dozens of people worldwide who are reduced to highlighting their text with xterm-76, instead of fungal hallucination pink and sizzling watermelon. Well, not anymore. I have named all the colors, and made the list available as an rgb.txt file, as well as a color to index lookup file.

If you know me, then you also know that the rest of this post is a winding article about colors, where I somehow find my way to explaining a data structure you don’t care about. But before we get to 3D nearest-neighbor search, a short overview of prior art.

Prior Art

There exists an attemp to map vim colors onto XTerm colors, reproduced e.g. in this list. The main problem with the result is that Vim doesn’t have enough colors to cover everything, and also it uses names like “Blue3”, which are boring.

A casual look around the internet reveals that people mostly use X11 colors (aka rgb.txt), SVG colors or Web Colors whenever they need to refer to terminal colors, but they don’t match up very well, for two reasons:

  1. There aren’t enough of them. X11 only knows about 130 colors, SVG and Web colors similar numbers.
  2. The shades are off - xterm-256 has a very specific set of swatches that are unlikely to appear in color database with 8 bits per channel.

As far as I could find, nobody has bothered to make an XTerm rgb.txt yet.

What are the XTerm colors

XTerm is terminal emulator that shipped with the X Window System, which you might know by its modern names X11 or X.org. It was one of the first emulators to ship support for 256 colors, and to this day, most terminal emulators behave like XTerm, and even declare themselves to be xterm-256color.

What I will now generously start refering to as the XTerm Colorspace consists of 3 groups of color swatches:

  1. 16 ANSI colors
  2. 216 RGB colors
  3. 24 shades of grey

Let’s start by looking at the simplest part - the ANSI colors.

ANSI Colors

from typing import NamedTuple


class Color(NamedTuple):
    """An RGB color with an optional list of names."""
    names: list[str]
    channels: tuple[float, float, float]


ANSI_COLORS_RGB = (
    (0x00, 0x00, 0x00),
    (0x80, 0x00, 0x00),
    (0x00, 0x80, 0x00),
    (0x80, 0x80, 0x00),
    (0x00, 0x00, 0x80),
    (0x80, 0x00, 0x80),
    (0x00, 0x80, 0x80),
    (0xC0, 0xC0, 0xC0),
    (0x80, 0x80, 0x80),
    (0xFF, 0x00, 0x00),
    (0x00, 0xFF, 0x00),
    (0xFF, 0xFF, 0x00),
    (0x00, 0x00, 0xFF),
    (0xFF, 0x00, 0xFF),
    (0x00, 0xFF, 0xFF),
    (0xFF, 0xFF, 0xFF),
)
"""The standard 16 ANSI colors."""

ANSI_COLOR_NAMES = (
    "Black",
    "Red",
    "Green",
    "Yellow",
    "Blue",
    "Magenta",
    "Cyan",
    "White",
    "Bright Black",
    "Bright Red",
    "Bright Green",
    "Bright Yellow",
    "Bright Blue",
    "Bright Magenta",
    "Bright Cyan",
    "Bright White",
)

ANSI_COLORS = [
    Color(
        names=[f"ansi-{i}", name],
        channels=tuple(c / 0xFF for c in ANSI_COLORS_RGB[i]),  # type: ignore
    )
    for i, name in enumerate(ANSI_COLOR_NAMES)
]

These are the ANSI colors. There are eight of them, and each additonally has a “bright variant”. They have names, too, but the names are strange. Let’s view them as an HTML table.

from IPython import display
from typing import Iterable


def rgb_to_hex(rgb: tuple[float, float, float]) -> str:
    """Convert an RGB color to a hex string like ff0000 for red."""
    return "".join([f"{int(ch*255):02x}" for ch in rgb])


def contrast_color(rgb: tuple[float, float, float]) -> tuple[float, float, float]:
    """Return a color that will be legible against the background color."""
    r, g, b = rgb
    if r * 0.9 + g * 1.3 + b * 0.7 > 0.9:
        return (0, 0, 0)
    else:
        return (1, 1, 1)


def colors_html(colors: Iterable[Color]) -> Iterable[str]:
    """Generate an HTML table of colors."""
    yield "<table style>"
    for color in colors:
        fg_color = rgb_to_hex(contrast_color(color.channels))
        bg_color = rgb_to_hex(color.channels)
        yield f'<tr style="margin:0; background:#{bg_color}; color:#{fg_color}">'
        for name in color.names:
            yield f'<td style=\"border:none\">{name}</td>'
        yield f"<td style=\"border:none\">{bg_color}</td>"
        yield "</tr>"
    yield "</table>"



colors = (ANSI_COLORS[i] for i in range(16))
display.HTML("\n".join(colors_html(colors)))

ansi-0 Black 000000
ansi-1 Red 800000
ansi-2 Green 008000
ansi-3 Yellow 808000
ansi-4 Blue 000080
ansi-5 Magenta 800080
ansi-6 Cyan 008080
ansi-7 White c0c0c0
ansi-8 Bright Black 808080
ansi-9 Bright Red ff0000
ansi-10 Bright Green 00ff00
ansi-11 Bright Yellow ffff00
ansi-12 Bright Blue 0000ff
ansi-13 Bright Magenta ff00ff
ansi-14 Bright Cyan 00ffff
ansi-15 Bright White ffffff

You might notice that some of these colors are ridiculous. “Bright White” is a color most humans would just call “White”, and of course “Bright Black” is non-sensical. But at least they have names.

XTerm 256 colors and greyscale

The next thing on the list is the 216 RGB colors, followed by some shades of grey. Before we visualize them, a word about how all of that works.

Modern colors, are mostly 24-bit (8 bits per RGB channel, like the familiar 0xff00ff), or 32-bit (adds an alpha channel). All this is sometimes called True Color.

XTerm has 8-bit colors, which means that the color of any character on the screen is given by a single byte with values from 0x00 to 0xff. The astute reader will notice that there is no way to divide 8 by 3 and end up happy, unless 2-bit channels are the kind of thing that makes you happy.

Base-6 all day, baby

Some 256-color systems hand-pick their color shades and use a lookup table, but not XTerm. XTerm is old school UNIX stuff, and so XTerm uses base-6 channels. An RGB color with 6 values per channel gives 216 combinations, which lets us put legacy ANSI at the beginning for backwards compatibility, and still leaves room at the end for fancy shades of grey and stuff.

The base-6 channels are converted to decimal as you’d expect - the only gotcha is that that 256 can’t be divided by 6, and also dark colors are pointless in terminals with black background, and so the possible non-zero channel values are all above 0x5f.

Here’s some quick conversion code from XTerm index into RGB:

# The xterm-256 color space is sparse at low luminosity.
CHANNEL_STEPFUNC = (0, 0x5F, 0x87, 0xAF, 0xD7, 0xFF)


def xterm_color(color_index: int) -> Color:
    """Convert the xterm color (0-255) to an RGB equivalent."""
    if color_index < 16:
        return Color(
            [f"{color_index}"],
            (
                ANSI_COLORS_RGB[color_index][0] / 0xFF,
                ANSI_COLORS_RGB[color_index][1] / 0xFF,
                ANSI_COLORS_RGB[color_index][2] / 0xFF,
            ),
        )

    if color_index >= 232:
        # Greyscale
        value = (0x8 + ((color_index - 232) * 0xA)) / 255
        return Color([f"{color_index}"], (value, value, value))

    red, r = divmod(color_index - 16, 6**2)
    green, blue = divmod(r, 6)

    return Color(
        [f"{color_index}"],
        (
            CHANNEL_STEPFUNC[red] / 255,
            CHANNEL_STEPFUNC[green] / 255,
            CHANNEL_STEPFUNC[blue] / 255,
        ),
    )

colors = [xterm_color(i) for i in range(0, 256)]
display.HTML("\n".join(colors_html(colors[30:45])))
30 008787
31 0087af
32 0087d7
33 0087ff
34 00af00
35 00af5f
36 00af87
37 00afaf
38 00afd7
39 00afff
40 00d700
41 00d75f
42 00d787
43 00d7af
44 00d7d7

Here we’re looking at a slice of the XTerm colorspace. It looks kind of like nothing, but let’s try sorting it by hue.

import colorsys

colors.sort(key=lambda x: colorsys.rgb_to_hsv(*x.channels))
display.HTML("\n".join(colors_html(colors[45:60])))
1 800000
88 870000
124 af0000
160 d70000
9 ff0000
196 ff0000
209 ff875f
216 ffaf87
173 d7875f
202 ff5f00
166 d75f00
223 ffd7af
180 d7af87
137 af875f
215 ffaf5f

Much better. Now we’re looking at a few shades of red and orange, which are looking quite attractive, if I say so myself. One small wart is that 9 and 196 are the same color. This is expected - some of the ANSI colors also appear in the base-6 part of the colorspace.

While xterm-202 is a satisfying shade of of orange, its name doesn’t quite roll of the tongue, or tell us anything about what color to expect.

So let’s get to naming them.

Naming XTerm Colors

My plan is to get a large color database, find shades similar to each XTerm color, and then pick one of the closest matches and use the name for the XTerm color.

First, let’s grab a color database. A cursory online search leads us to a Github project by the user meodai:

%pip install requests > /dev/null
import requests
import csv
import os


def get_medoai_colors() -> Iterable[Color]:
    """Get the color names from the meodai color names project."""
    MEODAI_ALL_COLORS_URL = (
        "https://github.com/meodai/color-names/raw/v10.19.0/dist/colornames.csv"
    )
    CACHE_PATH = ".cache_colornames.csv"
    if not os.path.exists(CACHE_PATH):
        response = requests.get(MEODAI_ALL_COLORS_URL)
        response.raise_for_status()
        response.encoding = "utf-8"
        with open(CACHE_PATH, "w") as cache_file:
            cache_file.write(response.text)

    with open(CACHE_PATH, "r") as cache_file:
        reader = csv.DictReader(cache_file)
        for row in reader:
            rgb = hex_to_rgb(row["hex"])
            yield Color([row["name"]], rgb)


def hex_to_rgb(hex_color: str) -> tuple[float, float, float]:
    """Convert a hex color (e.g. #ff00ff) to an RGB equivalent."""
    hex_color = hex_color.lstrip("#")
    r = int(hex_color[0:2], 16) / 255
    g = int(hex_color[2:4], 16) / 255
    b = int(hex_color[4:6], 16) / 255
    return (r, g, b)


medoai_colors = list(get_medoai_colors())

f"Database contains {len(medoai_colors)} colors"
'Database contains 30241 colors'

The RGB Color Space

The database contains a lot of colors. Let’s take a look at them. A good way to visualize RGB colors is by assinging each color channel to one of the 3D axes, like below.

First, we’ll need library:

%pip install ipympl > /dev/null
%matplotlib widget

Now some simple drawing code. Each color in the database will be shown at the coordinates given by its RGB value.

import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D
from typing import cast
from matplotlib.figure import Figure
import random


def plot_color_dots(
    ax: Axes3D,
    rate: float,
    labels: tuple[str, str, str],
    colors: Iterable[tuple[tuple[float, float, float], Color]],
):
    """Plot a scatter plot of colors in 3D."""
    ax.set_xlabel(labels[0])
    ax.set_ylabel(labels[1])
    ax.set_zlabel(labels[2])

    x: list[float] = []
    y: list[float] = []
    z: list[float] = []
    rgb: list[tuple[float, float, float]] = []
    for (r, g, b), color in colors:
        if random.random() > rate:
            continue
        x.append(r)
        y.append(g)
        z.append(b)
        rgb.append(color.channels)

    ax.scatter3D(x, y, z, c=rgb)  # type: ignore


def color_space_plot() -> tuple[Figure, Axes3D]:
    """Create a 3D scatter plot of the color space."""
    fig = plt.figure()
    axes = cast(Axes3D, fig.add_subplot(projection="3d"))
    axes.set_aspect("equal")
    axes.view_init(30, 30, 0)
    return (fig, axes)

fig, ax = color_space_plot()
plot_color_dots(ax, 1.0, ("Red", "Green", "Blue"), [(color.channels, color) for color in medoai_colors])
plt.show()
Figure

Nearest-neighbor color matching

For each XTerm color, we want to find its nearest neighbors in the database. This would mean checking each XTerm color’s 3D distance against every color in the database - in other words, the complexity is $O(N^2)$. With this database, that would mean $30,000 \times 216 \approx 6,500,000$ distance checks. Not too bad, on a modern computer, but not great if you want to reuse this database for live lookups (and I do).

If we only had greyscale colors, then finding the nearest match would be easy: just sort the array and do binary search. But we have three channels (R, G and B), so we have to do something a little smarter.

In video games, collision detection and raytracing have to solve a similar problem: checking ovelap between every pair of triangles in the scene would be $O(N^2)$, but by dividing space into smaller volumes, it’s possible to quickly find things are that nearby, for some definition of nearby.

The most common data-structure for dividing space into such volumes is the humble binary tree. Each node splits its bounding volume in two. The only trick is that each level does the splitting along a different axis. This type of tree has two names, depending on how we decide where to split the axis:

If the axis is always split down the middle, then we end up dividing the space into smaller and smaller half-cubes, and this is called a k-d tree.

Alternatively, we can split the axis so that each side of the subtree contains the same number of elements - the resulting volumes are smaller and uneven, along with some empty spaces, but each volume is guaranteed to contain something. Such a tree is called a bounding volume hierarchy, or BVH for short.

As it turns out, RGB colors also exist in 3D space, with similar-looking colors being near each other, so we can use this idea and build a BVH.

Bounding Volumes

The BV in BVH stands for “Bounding Volume”. This is basically just a simple 3D shape that’s exactly large enough to completely contain some other, more complicated 3D shape, like a point cloud. The simplest bounding volumes are spheres and non-rotated boxes, called Axis-Aligned Bounding Boxes, or AABB. We could actually split the color space into spheres, but an AABB is traditional, so let’s define one.

from typing import NamedTuple, Any, Iterable


class AABB(NamedTuple):
    """Axis-aligned bounding box."""

    lo: tuple[float, float, float]
    hi: tuple[float, float, float]

    @classmethod
    def from_point(cls, point: tuple[float, float, float]) -> "AABB":
        """A convenience constructor same as calling AABB(point, point)."""
        return cls(point, point)

    def overlaps(self, other: "AABB") -> bool:
        """Does this volume overlap with the other volume?"""
        return (
            self.hi[0] >= other.lo[0]
            and self.lo[0] <= other.hi[0]
            and self.hi[1] >= other.lo[1]
            and self.lo[1] <= other.hi[1]
            and self.hi[2] >= other.lo[2]
            and self.lo[2] <= other.hi[2]
        )

    def encapsulate(self, other: "AABB") -> "AABB":
        """Return a new AABB that bounds both self and the other."""
        return AABB(
            lo=(
                min(self.lo[0], other.lo[0]),
                min(self.lo[1], other.lo[1]),
                min(self.lo[2], other.lo[2]),
            ),
            hi=(
                max(self.hi[0], other.hi[0]),
                max(self.hi[1], other.hi[1]),
                max(self.hi[2], other.hi[2]),
            ),
        )

    def axis_center(self, axis: int) -> float:
        """The linear center of this box for the given axis."""
        return (self.lo[axis] + self.hi[axis]) / 2


def bounding_volume(volumes: Iterable[AABB]) -> AABB:
    """Bring all the volumes and in the darkness bound them."""
    volumes = iter(volumes)
    result = next(volumes)
    for volume in volumes:
        result = result.encapsulate(volume)
    return result


BVH

OK, we have the volume defined, now what about the hierarchy? As discussed, a BVH is a binary tree that divides a population of 3D volumes into half with each step. The algorithm to query a BVH is a little different a regular BST, because a volume might possibly overlap both left and right.

DIMENSIONS = 3


class BVH(NamedTuple):
    """Bounding volume hierarchy."""

    bounds: AABB
    value: Color|None
    left: "BVH|None"
    right: "BVH|None"
    depth: int

    def search(self, bounds: AABB) -> Iterable[tuple[AABB, Color]]:
        """Search for all values within the given bounds."""
        if self.bounds.overlaps(bounds):
            if self.value is not None:
                yield (self.bounds, self.value)
            if self.left is not None:
                yield from self.left.search(bounds)
            if self.right is not None:
                yield from self.right.search(bounds)

Building a BVH

The algorithm to construct a BVH is almost identical to quicksort. Just like in quicksort, each level of recursion picks a pivot element, and then swaps elements until everything left of the pivot is smaller and everything right of the pivot greater than the pivot.

Just like with quicksort, we need to pick a partition function - the code below uses to often-taught Hoare Partition with a median-of-three algorithm for pivot selection.


def make_bvh(data: list[tuple[AABB, Color]]) -> BVH:
    """Create a BVH from a list of AABBs and values."""
    if not data:
        raise ValueError("Must provide at least one volume")
    bounds = bounding_volume(aabb for aabb, _ in data)
    res = _make_bvh(data, 0, len(data) - 1, bounds, 0)
    assert res is not None
    return res


def _make_bvh(
    data: list[tuple[AABB, Any]], lo: int, hi: int, bounds: AABB, depth: int
) -> BVH | None:
    n = hi - lo + 1
    if n == 0:
        return None
    if n == 1:
        return BVH(bounds=data[lo][0], value=data[lo][1], left=None, right=None, depth=depth)
    if n == 2:
        return BVH(
            bounds=bounds,
            value=None,
            left=BVH(
                bounds=data[lo][0],
                value=data[lo][1],
                left=None,
                right=None,
                depth=depth + 1,
            ),
            right=BVH(
                bounds=data[hi][0],
                value=data[hi][1],
                left=None,
                right=None,
                depth=depth + 1,
            ),
            depth=depth,
        )

    # This part is basically quicksort, but with the partition using different
    # axes based on depth.
    axis = depth % DIMENSIONS
    mid = _hoare_partition(data, lo, hi, axis)
    left_bounds = bounding_volume(aabb for aabb, _ in data[lo:mid])
    right_bounds = bounding_volume(aabb for aabb, _ in data[mid : hi + 1])
    return BVH(
        bounds=bounds,
        value=None,
        left=_make_bvh(data, lo, mid - 1, left_bounds, depth + 1),
        right=_make_bvh(data, mid, hi, right_bounds, depth + 1),
        depth=depth,
    )


def _hoare_partition(a: list[tuple[AABB, BVH]], lo: int, hi: int, axis: int) -> int:
    pivot = a[_median_of_three(a, lo, hi, axis)][0].axis_center(axis)
    i = lo - 1
    j = hi + 1
    while True:
        i += 1
        while a[i][0].axis_center(axis) < pivot:
            i += 1
        j -= 1
        while a[j][0].axis_center(axis) > pivot:
            j -= 1
        if i >= j:
            return j
        a[i], a[j] = a[j], a[i]


def _median_of_three(a: list[tuple[AABB, BVH]], lo: int, hi: int, axis: int) -> int:
    """Median of three pivot selection adapted from quicksort."""
    mid = (lo + hi) // 2
    if a[mid][0].axis_center(axis) < a[lo][0].axis_center(axis):
        a[lo], a[mid] = a[mid], a[lo]
    if a[hi][0].axis_center(axis) < a[lo][0].axis_center(axis):
        a[lo], a[hi] = a[hi], a[lo]
    if a[mid][0].axis_center(axis) < a[hi][0].axis_center(axis):
        a[mid], a[hi] = a[hi], a[mid]
    return mid

We have a BVH - let’s test it by looking for some colors similar to #FF8000, and see what we find.

color_db = make_bvh([(AABB.from_point(color.channels), color) for color in medoai_colors])
list(color_db.search(AABB((0.9, 0.5, 0.0), (1.0, 0.55, 0.0))))
[(AABB(lo=(0.9019607843137255, 0.5411764705882353, 0.0), hi=(0.9019607843137255, 0.5411764705882353, 0.0)),
  Color(names=['Hotter Butter'], channels=(0.9019607843137255, 0.5411764705882353, 0.0))),
 (AABB(lo=(0.9333333333333333, 0.5333333333333333, 0.0), hi=(0.9333333333333333, 0.5333333333333333, 0.0)),
  Color(names=['Clear Orange'], channels=(0.9333333333333333, 0.5333333333333333, 0.0))),
 (AABB(lo=(0.9411764705882353, 0.5137254901960784, 0.0), hi=(0.9411764705882353, 0.5137254901960784, 0.0)),
  Color(names=['Mikan Orange'], channels=(0.9411764705882353, 0.5137254901960784, 0.0))),
 (AABB(lo=(0.9490196078431372, 0.5215686274509804, 0.0), hi=(0.9490196078431372, 0.5215686274509804, 0.0)),
  Color(names=['Tangerine Skin'], channels=(0.9490196078431372, 0.5215686274509804, 0.0))),
 (AABB(lo=(1.0, 0.5450980392156862, 0.0), hi=(1.0, 0.5450980392156862, 0.0)),
  Color(names=['American Orange'], channels=(1.0, 0.5450980392156862, 0.0))),
 (AABB(lo=(1.0, 0.5490196078431373, 0.0), hi=(1.0, 0.5490196078431373, 0.0)),
  Color(names=['Sun Crete'], channels=(1.0, 0.5490196078431373, 0.0))),
 (AABB(lo=(1.0, 0.5333333333333333, 0.0), hi=(1.0, 0.5333333333333333, 0.0)),
  Color(names=['Mandarin Jelly'], channels=(1.0, 0.5333333333333333, 0.0))),
 (AABB(lo=(1.0, 0.5176470588235295, 0.0), hi=(1.0, 0.5176470588235295, 0.0)),
  Color(names=['The New Black'], channels=(1.0, 0.5176470588235295, 0.0)))]

OK, that looks about right, we have a few shades of orange. To understand how the BVH works, though, it’d be helpful to visualize the recursive search in 3D, like we did with the colors themselves. First, let’s slightly modify the search function to generate a trace.

def trace_bvh_search(bvh, bounds: AABB) -> Iterable[BVH]:
    """Search for all values within the given bounds, yielding a full trace,
    including nodes without values."""
    if bvh.bounds.overlaps(bounds):
        yield bvh
        if bvh.left is not None:
            yield from trace_bvh_search(bvh.left, bounds)
        if bvh.right is not None:
            yield from trace_bvh_search(bvh.right, bounds)

Now we need a way to draw a 3D box. GPUs can only draw triangles, from what’s called a mesh. A mesh is commonly given as two arrays:

  1. An array of vertices - points in 3D space
  2. An array of triangles - triples of vertex numbers that form a triangle

Our 3D library does us a solid (that’s a graphics pun) and lets us specify arbitrary polygons, like rectangles, and it splits those into triangles for us. So what we need to provide is a list of vertices, and their connections.

Any 3D “box” is always going to have the same connections between its vertices, so that part is simple:

BOX_SIDES = [
    (0, 1, 2, 3),
    (0, 4, 5, 1),
    (1, 5, 6, 2),
    (2, 6, 7, 3),
    (3, 7, 4, 0),
    (4, 5, 6, 7),
]
"""The indices of the corners of each face of a box."""

'The indices of the corners of each face of a box.'

We can get vertices from the AABB volume we declared earlier, like so:

import numpy
from typing import Collection


def aabb_vertices(aabb: AABB) -> Collection[tuple[float, float, float]]:
    return numpy.array(
        [
            aabb.lo,
            (aabb.hi[0], aabb.lo[1], aabb.lo[2]),
            (aabb.hi[0], aabb.hi[1], aabb.lo[2]),
            (aabb.lo[0], aabb.hi[1], aabb.lo[2]),
            (aabb.lo[0], aabb.lo[1], aabb.hi[2]),
            (aabb.hi[0], aabb.lo[1], aabb.hi[2]),
            aabb.hi,
            (aabb.lo[0], aabb.hi[1], aabb.hi[2]),
        ]
    )


def aabb_mesh(vertices) -> Any:
    """Return the vertices of the box as a list of faces, in the format that
    pyplot expects."""
    return [[vertices[i] for i in side] for side in BOX_SIDES]

Now we just need some unremarkable drawing code, lifted straight from the documentation.

from matplotlib import pyplot as plt
from mpl_toolkits.mplot3d.art3d import Poly3DCollection


def plot_aabb(axes: Any, aabb: AABB, color: str = "#0000ff", alpha: float = 0.05):
    v = aabb_vertices(aabb)
    axes.add_collection3d(
        Poly3DCollection(
            aabb_mesh(v),
            facecolors=color,
            linewidths=0.1,
            edgecolors="k",
            alpha=alpha,
            zsort="max",
        )
    )


def plot_bvh_trace(axes: Any, bvh: BVH, bounds: AABB) -> None:
    for node in trace_bvh_search(bvh, bounds):
        if node.value is None:
            plot_aabb(axes, node.bounds, color="#0000ff", alpha=0.0001)
        else:
            plot_aabb(
                axes, node.bounds, color=f"#{rgb_to_hex(node.value.channels)}", alpha=0.0001
            )


fig, axes = color_space_plot()
plot_bvh_trace(axes, color_db, AABB((0.9, 0.5, 0.0), (1.0, 0.55, 0.0)))

plt.show()
Figure

And there you go - a BVH search for a few shades of orange. Each recursion level is smaller, and most of the tree didn’t need to be checked.

We can use this same approach to find colors similar to other colors.

Finding Similar Shades

MAX_MATCHES = 5

for color in colors:
    r, g, b = color.channels
    max_dist = 0.15
    near_matches = list(
        color_db.search(
            AABB(
                lo=(r - max_dist, g - max_dist, b - max_dist),
                hi=(r + max_dist, g + max_dist, b + max_dist),
            )
        )
    )
    # Sort the matches by square distance
    dist = lambda x: sum((a - b) ** 2 for a, b in zip(x[0].lo, (r, g, b)))
    near_matches.sort(key=dist)
    for _, color2 in near_matches[:MAX_MATCHES]:
        color.names.extend(color2.names)

display.HTML("\n".join(colors_html(colors)))
0 Black Vantablack Registration Black Black Hole Armor Wash 000000
16 Black Vantablack Registration Black Black Hole Armor Wash 000000
232 Reversed Grey Accursed Black Badab Black Wash Black Metal Existential Angst 080808
233 Dark Tone Ink Sticky Black Tarmac Dreamless Sleep Cursed Black Glimpse into Space 121212
234 Eerie Black Coco’s Black Gluon Grey Siyâh Black Dynamic Black 1c1c1c
235 Nero Bitter Liquorice Dire Wolf Bokara Grey Darth Vader 262626
236 Off Black Tricorn Black Coated Tap Shoe Black Cat 303030
237 Dead Pixel Black Liquorice Boltgun Metal Montana Limousine Leather 3a3a3a
238 Goshawk Grey Medium Black Greenish Black Machine Gun Metal Vulcanized 444444
239 Black Oak Charadon Granite Perle Noir Thunder Fiftieth Shade of Grey 4e4e4e
240 Shadow Mountain Charcoal Dust Sumi Ink Carbon Dating Industrial Grey 585858
59 Rhine Castle Shades On Charcoal Smudge Iron Hematite 5f5f5f
241 Kettleman Grizzle Grey Digital Roycroft Pewter Tornado Wind 626262
242 Dove Grey Scapa Flow Boat Anchor Shadows Dark Ash 6c6c6c
243 Lucky Grey Sonic Silver Steel Wool Iron Mountain Riverstone 767676
8 Grey Pound Sterling Mt. Rushmore Trolley Grey Captain Nemo 808080
244 Grey Pound Sterling Mt. Rushmore Trolley Grey Captain Nemo 808080
102 Mithril Lunar Base Looking Glass Argent Jumbo 878787
245 Wild Dove Argent Falcon Grey Heavy Rain Pewter Ring 8a8a8a
246 Grey Shingle Moonlit Orchid Grey Summit Nickel Shark Fin 949494
247 Mortar Grey Smoky Tone Waiting Cold Grey Hugh’s Hue 9e9e9e
248 Uniform Grey Nosferatu Elephant in the Room Ultimate Grey Moon Landing a8a8a8
145 Smoke Screen Aluminum Sky Bombay Industrial Age Tin Foil afafaf
249 Tangled Web Palladium Silverstone Harbour Fog Praise Giving b2b2b2
250 Dust to Dust Glacier Grey Gravel Fint Alaskan Grey Anti Rainbow Grey bcbcbc
7 Silver Stonewall Grey Waxwing Silver Tipped Sage Neo Tokyo Grey c0c0c0
251 Silver Polish Autonomous Dreamscape Grey Paternoster Lunar Rock c6c6c6
252 Ancestral Water Cool Elegance Clouded Vision American Silver White Metal d0d0d0
188 Cape Hope Silver Medal Tundra Windchill Desired Dawn d7d7d7
253 Porpoise Subtle Touch Urban Snowfall Orochimaru Silver Setting dadada
254 Titanium White Windswept Beach Tripoli White Grey Whisper Cold Morning e4e4e4
255 Super Silver White Whale Essence of Violet Crystal Bell White Edgar eeeeee
15 White White as Heaven Whitecap Snow Pale Grey Polar Bear In A Blizzard ffffff
231 White White as Heaven Whitecap Snow Pale Grey Polar Bear In A Blizzard ffffff
224 We Peep Forgotten Pink Cottagecore Sunset Go Go Pink Satin Ribbon ffd7d7
181 Mary Rose Radiant Rouge Victoriana Pale Persimmon Ballet Rose d7afaf
138 Woodrose Orchid Red Warm Comfort Audrey’s Blush Retro Pink af8787
95 Rabbit Paws Aged Beech Tarsier Forbidden Thrill Rose Garland 875f5f
217 Fancy Flamingo Cornflower Lilac Wildflower Bouquet Peach Bud Apricot Haze ffafaf
174 Peaches of Immortality Copperfield Rhubarb Pie Finest Blush Mauve Glow d78787
131 Italian Villa Poppy Prose Sienna Red Cinnamon Candle Spiced Tea af5f5f
210 Red Mull Coral Trails Tulip Prime Pink Camaron Pink ff8787
167 Roman Happy Hearts Salami Slice Deep Sea Coral Tory Red d75f5f
203 Fusion Red Pineapple Salmon Pompelmo Pastel Red Grapefruit ff5f5f
52 Spikey Red Red Blood Vampire Hunter Soooo Bloody Khorne Red 5f0000
1 Maroon Salami Dark Red Sacrifice Altar Chanticleer 800000
88 Chanticleer Glass Bull Sacrifice Altar Dark Red Scab Red 870000
124 Red Door Velvet Volcano Heartbeat Artful Red Red Pentacle af0000
160 Red Republic Rosso Corsa Hot Fever Red Pegasus Red Epiphyllum d70000
9 Red Rainbow’s Outer Rim Fire Engine Encarnado Left on Red ff0000
196 Red Rainbow’s Outer Rim Fire Engine Encarnado Left on Red ff0000
209 After Burn Pink Fire Protein High Mango Orange Nectarine ff875f
216 Spice Pink Sunset over the Alps Super Sepia Coral Correlation Coral Dusk ffaf87
173 Bright Sienna Copper Tan Harvest Time Georgian Leather Show Business d7875f
202 Vivid Orange Maximum Orange Molten Core Safety Orange Willpower Orange ff5f00
166 Exuberance Ancient Bamboo Orange Danger Raging Leaf Tenné d75f00
223 Delicious Melon Acini di Pepe Venus Deathtrap Forgotten Sunset Satin Latour ffd7af
180 Porcini Santa Fe Tan Calico Caramel Cloud Buttery Leather d7af87
137 Clay Ochre Roman Coin Light Oak Brown Caramel Kiss Sacred Ground af875f
215 Vintage Orange Dreamy Sunset Burning Flame Rajah Mango Salsa ffaf5f
208 Mandarin Jelly The New Black American Orange Sun Crete Orange Juice ff8700
130 Orange Brown Orangish Brown Umber Ginger Butter Fudge af5f00
172 Fleur de Sel Caramel Fox Tails Harvest Eve Gold Orange Pepper Fulvous d78700
222 Workout Routine Surfboard Yellow Big Bus Yellow Oberon Egg Cream ffd787
179 Sell Gold Butterscotch Bliss Equator Stranglethorn Ochre French Pale Gold d7af5f
214 Fresh Squeezed Clementine Jelly Frenzy Yellow Exhilaration Imperial Yellow ffaf00
94 Rat Brown Soil Of Avagddu Alligator Ground Earth Hè Sè Brown 875f00
221 Common Dandelion Lemon Twist Aspen Gold Naples Yellow Yellow Stagshorn ffd75f
136 Strong Mustard Chestnut Gold Mustard Brown Bark Sawdust Cobra Leather af8700
178 Palomino Gold Deadly Yellow Chinese Gold Mustard Burnt Yellow d7af00
220 Gold School Bus Soviet Gold Cyber Yellow Evil-Lyn ffd700
230 Poetic Yellow Lit Sun City Pumpkin Seed Matt White ffffd7
187 Green Mesh Morning Moor Kohlrabi Green Mǐ Bái Beige Dull Sage d7d7af
144 Daddy-O Lively Ivy Wall Green Underhive Ash New Bamboo afaf87
101 Tilleul de Noémie Green Savage Green Scene Spinach Souffle Bandicoot 87875f
229 VIC 20 Creme Parchment Creamy Sunshine Pastel Ginger Lemon Tea Bollywood Gold ffffaf
186 Wax Green Treasury Lime Ice Garlic Toast Golden Delicious d7d787
143 Palm Palm Frond Green Me April Green Hemp Tea afaf5f
228 Cinque Foil Yippie Yellow Aged Plastic Casing Butter Yellowish Tan ffff87
185 Banana Chalk Chinese Green Energized Species Sequesta d7d75f
227 Canary Candy Corn Duckling Fluff Laser Lemon Unmellow Yellow ffff5f
58 Mud Green Earthy Khaki Green Green Brown Ayahuasca Vine Serrano Pepper 5f5f00
3 Heart Gold Drably Olive Verde Tropa Mongolian Plateau Swamp Green 808000
100 Drably Olive Heart Gold Old Asparagus Krypton Green Moscow Papyrus 878700
142 Honey and Thyme Mustard Green Dark Citron Yew Sulphine Yellow afaf00
184 Chartreuse Shot Golden Gun March Green Mogwa-Cheong Yellow Octarine d7d700
11 Yellow Bat-Signal Yell for Yellow Lemon Glacier Bright Yellow ffff00
226 Yellow Bat-Signal Yell for Yellow Lemon Glacier Bright Yellow ffff00
190 Lime Zest Citron Goby Bitter Lime Dancing-Lady Orchid Neon Yellow d7ff00
148 King Lime Slimer Green Immaculate Iguana Vivid Lime Green High Grass afd700
106 Fresh Lawn Dark Lime Brilliant Green Seasoned Apple Green Grasping Grass 87af00
191 Isotonic Water Pear Spritz Green of Bhabua Ultra Moss Tennis Ball d7ff5f
64 Pesto Alla Genovese Avocado Olive Green Topiary Green Pistachio Flour 5f8700
154 Lime Acid Lime Candy Pearl Lemon Green Wolf Lichen Spring Bud afff00
192 Green Shimmer Honeydew Peel Mystic Green Green Incandescence Sunny Lime d7ff87
149 Lime Lizard Juicy Lime Last of Lettuce Citrus Leaf Badass Grass afd75f
112 Overgrown Electric Leaf Alien Armpit Sheen Green Green Cape 87d700
70 Kermit Green Leaf Green Appetizing Asparagus Yoshi Emerald Glitter 5faf00
118 Lasting Lime Bright Lime Radioactive Radium Mochito 87ff00
193 Lime Mist Sour Green Cherry Greedy Green Breeze of Green Distilled Venom d7ffaf
150 Fresh Lettuce Peas In A Pod Wasabi Pastel Lime Feijoa afd787
107 Broccoli Chelsea Cucumber Jealousy Celuce Nasturtium Leaf 87af5f
155 Pale Lime Green Irradiated Green Luminescent Lime Stinging Wasabi Key Lime afff5f
76 Radioactive Lilypad Tropical Funk Corrosive Green Blinking Terminal Composite Artefact Green 5fd700
82 Bright Green Fertility Green Hyper Green Bright Lime Green Sparkling Green 5fff00
156 Pistachio Mousse Pale Light Green Light Yellowish Green Green Day Green Incandescence afff87
113 Lilliputian Lime Bright Lettuce Vivid Spring Amazon Parrot Fairy Tale Green 87d75f
119 Poisonous Dart Lighter Green Stadium Lawn Amazon Parrot Astro Arcade Green 87ff5f
194 Transparent Green Aquarelle Mint Frosted Plains Light Carolina Mint Zest d7ffd7
151 Flower Stem Fizz Big Spender Coral Springs Pastel Mint Green afd7af
108 Chatty Cricket Shaded Willow Peapod Meadow Mermaid’s Cove 87af87
65 Hippie Green Tuscan Herbs Soylent Green Clouded Pine Spring Garden 5f875f
157 Creamy Mint Light Seafoam Green Light Mint Green Light Pastel Green Light Mint afffaf
114 Greek Garden De York Electric Lettuce Feralas Lime VIC 20 Green 87d787
71 Boring Green Chlorella Green Endo Exploration Green Techno Green 5faf5f
120 Easter Green Cobalt Green Radar Blip Green Light Green Ulva Lactuca Green 87ff87
77 Lightish Green Koopa Green Shell Scorpion Green Fresh Green Loud Green 5fd75f
83 Screamin’ Green Biopunk Green Katamari Puyo Blob Green Goblin Warboss 5fff5f
22 Cucumber Duck Hunt Pakistan Green Forest Ride Emerald Green 005f00
2 Hulk Lucky Clover Green Hills Fine Pine Moth Green 008000
28 Fine Pine Lucky Clover Hulk Clover Maniac Green 008700
34 Phosphor Green Aquamentus Green Bubble Bobble Green Green Glimmer Waystone Green 00af00
40 Greenalicious Nuclear Throne Tunic Green Demeter Green Vibrant Green 00d700
10 Green Venom Dart Electric Pickle Spring EGA Green 00ff00
46 Green Venom Dart Electric Pickle Spring EGA Green 00ff00
84 Thallium Flame Grotesque Green Flora Light Green Goblin Green 5fff87
121 Esper’s Fungus Green Green Epiphany Foam Green Mild Menthol Mint Bliss 87ffaf
78 Spring Bouquet Van Gogh Green Grotesque Green Vegetation Snow Pea 5fd787
47 Cathode Green Booger Buster Mike Wazowski Green Spring Green Guppie Green 00ff5f
41 Alienated Malachite Limonana Benzol Green Green Priestess 00d75f
158 Mintastic Pale Turquoise Seafair Green Icery Neo Mint afffd7
115 Jovial Jade Tropical Trail Pharaoh’s Jade Marooned Aquamarine Ocean 87d7af
72 Verdigris Green Green Tourmaline Jade Cream Sea Grass Crazy Eyes 5faf87
85 Ineffable Green Hanuman Green Venice Green Illicit Green Sea Green 5fffaf
48 Guppie Green Spring Green Turquoise Green Ahaetulla Prasina Booger Buster 00ff87
35 Go Green! Greedo Green Rita Repulsa Alhambra Green Jungle 00af5f
42 Underwater Fern Benzol Green Caribbean Green Cōng Lǜ Green Aqua Green 00d787
122 Tibetan Plateau Roller Derby Hiroshima Aquamarine A State of Mint Calamine BLue 87ffd7
79 Medium Aquamarine Sweet Garden Tropical Tide Aquarium Blue Jamaican Jade 5fd7af
49 Greenish Turquoise Enthusiasm Yíng Guāng Sè Green Minty Paradise Night Pearl 00ffaf
29 Absinthe Turquoise Chagall Green Spectral Green Spanish Viridian Bosphorus 00875f
86 Rare Wind Move Mint Spindrift Icy Life Near Moon 5fffd7
36 Arcadia Simply Green Hobgoblin Chromophobia Green Moray Eel 00af87
43 Channel Marker Green Lifeless Green Mint Leaf Pristine Oceanic Malted Mint Madness 00d7af
50 Ice Ice Baby Plunge Pool Vibrant Mint Frozen Boubble Bright Teal 00ffd7
195 Refreshing Primer Salt Mountain Mount Olympus Cold Canada Ice d7ffff
152 Rivers Edge Light Continental Waters Sugar Pool Light Imagine Wave Top afd7d7
109 Hydrology Bon Voyage Cold Front Green Shallow Water Ground Jitterbug Lure 87afaf
66 Steel Teal Arctic Cretan Green Tasmanian Sea Pond Sedge 5f8787
159 Celeste Italian Sky Blue Frostbite Affen Turquoise Winter Meadow afffff
116 Island Oasis Mountain Lake Blue Vibrant Soft Blue Rainwater VIC 20 Sky 87d7d7
73 Aquarelle Artesian Well Fountain Blue Experience Timid Sea 5fafaf
123 Glitter Shower Electric Blue Shallow Water Photon Projector Defense Matrix 87ffff
80 Hammam Blue Pluviophile Blue Radiance Watercourse Jazzy Jade 5fd7d7
87 Moonglade Water CGA Blue Electric Sheep Frozen Turquoise Aggressive Baby Blue 5fffff
23 Sandhill Crane Mosque Emerald Stone Enamelled Jewel Tidal Pool 005f5f
6 Teal Belly Flop Pond Bath Windows 95 Desktop Macquarie 008080
30 Green Moblin Green Lapis Navigate Well Blue Milky Aquamarine 008787
37 Fiji Jade Orchid Bluebird Cyan Sky Garish Blue 00afaf
44 Jade Glass Aztec Turquoise Mint Morning Tilla Kari Mosque First Timer Green 00d7d7
14 Aqua Spanish Sky Blue Fluorescent Turquoise Agressive Aqua Arctic Water 00ffff
51 Aqua Spanish Sky Blue Fluorescent Turquoise Agressive Aqua Arctic Water 00ffff
45 Neon Blue Tropical Turquoise Vivid Sky Blue Bright Sky Blue Whimsical Blue 00d7ff
38 Malibu Blue Blue Fire Blue Atoll Maldives Vanadyl Blue 00afd7
31 Stomy Shower Lyrebird Corfu Waters Tiny Bubbles Tusche Blue 0087af
81 Athena Blue Ionized-air Glow Skyan Electric Lemonade Heisenberg Blue 5fd7ff
24 Blue Flame Impulse Ink Blotch Blue League Blue Heist 005f87
39 Krishna Blue Protoss Pylon Blue Bolt Hawaii Morning Democrat 00afff
117 Tranquil Pool Kul Sharif Blue Drift on the Sea Platonic Blue Clear Sky 87d7ff
74 Flyway Disembark Shimmering Brook Riviera Blue Crystal Seas 5fafd7
32 Blue Cola Calgar Blue Electron Blue Kahu Blue Lvivian Rain 0087d7
25 Cobalt Stone Wing Commander Directoire Blue Peptalk Bottled Sea 005faf
33 Too Blue to be True Bubble Bobble P2 Brescian Blue Azure Starfleet Blue 0087ff
153 Ice Cold Stare Droplet Malmö FF Endless Horizon Night Snow afd7ff
110 Blue Bell Birdie Num Num Buoyant Blue Boy Blue Reform 87afd7
67 Pacific Coast Shrinking Violet Lichen Blue Perfect Periwinkle Sand Shark 5f87af
75 Tiān Lán Sky Âbi Blue Joust Blue Hello Summer Blue Jeans 5fafff
26 Blue Ruin Frosted Blueberries Royal Navy Blue Dead Blue Eyes Pacific Bridge 005fd7
27 Bright Blue Blue Ribbon Megaman Helmet Nīlā Blue Blue et une Nuit 005fff
111 Kitten’s Eye Carolina Blue Parakeet Blue Scenic Water Fly Away 87afff
68 Blue Jay Livid Berlin Blue Little Boy Blue Marina 5f87d7
69 Deep Denim Punch Out Glove Skinny Jeans Flickery C64 C64 NTSC 5f87ff
189 Transparent Blue Pale Lavender Icy Plains Nostalgia Perfume Contrail d7d7ff
146 Pixie Violet Freesia Purple Lavender Wash Delicate Lilac High Style afafd7
103 Aster Purple Non Skid Grey Persian Violet Papilio Argeotus Skysail Blue 8787af
60 Purple Balloon Pharaoh Purple Majestic Purple Idol Champion Blue 5f5f87
147 Winterspring Lilac Shy Moment Greyish Lavender Purple Illusion Dried Lilac afafff
104 Tanzine Bailey Bells Mystic Iris Adora Mood Mode 8787d7
61 Bellflower Yuè Guāng Lán Moonlight Blue Iris Evening Lagoon Blue Marguerite 5f5faf
105 Lavender Blue Shadow Periwinkle Blue Party Parrot Orchid Periwinkle Blue 8787ff
62 Exodus Fruit Dark Periwinkle Thick Blue Ameixa Majorelle Blue 5f5fd7
63 Blue Genie Blue Heath Butterfly Shady Neon Blue Blue Hepatica Flickering Sea 5f5fff
17 Abyssal Blue Alone in the Dark D. Darx Blue Alucard’s Night Prussian Nights 00005f
4 Navy Blue Midnight in Tokyo Scotch Blue Yves Klein Blue Deep Blue 000080
18 Midnight in Tokyo Yves Klein Blue Navy Blue Phthalo Blue Scotch Blue 000087
19 Bohemian Blue Traditional Royal Blue Antarctic Circle Keese Blue Cobalt 0000af
20 Bluealicious Medium Blue Lady of the Sea Pure Blue Nightfall in Suburbia 0000d7
12 Blue Graphical 80’s Sky Star of David Primary Blue Strong Blue 0000ff
21 Blue Graphical 80’s Sky Star of David Primary Blue Strong Blue 0000ff
99 Purple Anemone Purple Honeycreeper Blackthorn Berry Venetian Nights Irrigo Purple 875fff
141 Lilac Geode Purple Illusionist Liliac Illicit Purple Queer Purple af87ff
98 Gloomy Purple Matt Purple Iridescent Purple Amethyst Legendary Lavender 875fd7
57 Electric Indigo Aladdin’s Feather Bright Indigo Wèi Lán Azure Tezcatlipōca Blue 5f00ff
56 Trusted Purple Gonzo Violet Space Opera Violet Blue Sea Serpent’s Tears 5f00d7
183 Light Violet Testosterose Mauve Teasel Dipsacus Pink Illusion d7afff
140 Middy’s Purple Lavender Blossom Pale Purple Lenurple Fleur-De-Lis af87d7
97 Lusty Lavender Chive Blossom Genestealer Purple Lavish Spending Knight Elf 875faf
135 Purple Hedonist Vega Violet Lighter Purple Queer Purple Light Shōtoku Purple af5fff
93 Purple Climax Amethyst Ganzstar Violent Violet Poison Purple Star Platinum Purple 8700ff
55 Aubergine Perl Elegant Midnight Indiviolet Sunset Purplue Indigo Purple 5f00af
92 French Violet Vibrant Violet Violet Ink Violet Poison Voluptuous Violet 8700d7
177 Lavender Tea Crash Pink Grass Pink Orchid Pink Fetish Bright Lilac d787ff
134 Teldrassil Purple Rich Lilac Medium Orchid Rich Lavender Ripe Lavander af5fd7
129 Poison Purple Paradise Digital Violets Bright Violet The Grape War of 97’ Spectacular Purple af00ff
54 Peaceful Purple SQL Injection Purple Zeus Purple Extraviolet Pigment Indigo 5f0087
171 Flaming Flamingo After-Party Pink Pink Fever Jacaranda Pink Heliotrope d75fff
91 Shade of Violet Violet Poison Shiffurple Purple Feather Boa French Violet 8700af
128 Vibrant Purple Ferocious Fuchsia Capricious Purple Vivid Mulberry Foxy Fuchsia af00d7
165 Psychedelic Purple Phlox Electric Orchid Vivid Orchid Hot Purple d700ff
225 Sugarpills Pink Diamond Strawberry Frost Sugar Chic Silky Pink ffd7ff
182 Whisper of Plum Bff Confectionary Lilac Haze Sea Lavender d7afd7
139 Dusty Lavender Stage Mauve Voila! African Violet Fashionably Plum af87af
96 Candy Violet Dusty Purple Ancient Murasaki Purple Crushed Grape Orchid Orchestra 875f87
219 Jigglypuff Strawberry Buttercream Distilled Rose Sweet Slumber Pink Pink Apotheosis ffafff
176 Lavender Pink Blush Essence Purception Lavender Perceptions Hibiscus Pop d787d7
133 Royalty Loyalty Orchid Dottyback Mazzy Star Iris Orchid Pearly Purple af5faf
213 Darling Bud Bubble Gum Hottest Of Pinks Technolust Atomic Pink ff87ff
170 Free Speech Magenta Blueberry Glaze Thick Pink Fuchsia Flash Death of a Star d75fd7
207 Violet Pink Pink Flamingo Magenta Stream Ultimate Pink Surati Pink ff5fff
53 Clear Plum Purple Dreamer Divine Purple Amygdala Purple God of Nights 5f005f
5 Purple Philippine Violet Aunt Violet Mardi Gras Ultraberry 800080
90 Mardi Gras Dark Magenta Purple Philippine Violet Aunt Violet 870087
127 Energic Eggplant Purple Potion Purple Pirate Heliotrope Magenta Kinky Koala af00af
164 Fúchsia Intenso Fungal Hallucinations Passionate Pink Awkward Purple Screaming Magenta d700d7
13 Magenta Sixteen Million Pink Rainbow’s Inner Rim Brusque Pink Fresh Neon Pink ff00ff
201 Magenta Sixteen Million Pink Rainbow’s Inner Rim Brusque Pink Fresh Neon Pink ff00ff
200 Fuchsia Flame Hot Magenta Mademoiselle Pink Vice City Bright Magenta ff00d7
163 Aphroditean Fuchsia Explosive Purple Purple Pirate Fúchsia Intenso Passionate Pink d700af
126 Vibrant Velvet Katy Berry Blissful Berry Eye Popping Cherry King’s Plum Pie af0087
206 Illicit Pink Rose Pink Drunken Flamingo Purple Pizzazz Candy Pink ff5fd7
89 Grapest Cardinal Pink Xereus Purple Strong Cerise Patriarch 87005f
199 Mean Girls Lipstick Bright Pink Ms. Pac-Man Kiss Brutal Pink Shocking Pink ff00af
212 Pink Delight Princess Perfume Pout Pink Shimmering Love Angel Face Rose ff87d7
169 Mega Magenta Pú Táo Zǐ Purple Purple Hollyhock Orion Pink Charge d75faf
162 The Art of Seduction Snappy Violet Mexican Pink Vampire Love Story Benevolent Pink d70087
125 Velvet Cupcake Shy Guy Red Aztec Warrior Violet Red Banafsaji Purple af005f
198 Fancy Fuchsia Hot Pink Neon Rose Flickr Pink Strong Pink ff0087
218 Lavender Candy Spaghetti Strap Pink Pink Cattleya Fresh Gum Hot Aquarelle Pink ffafd7
175 Springtime Bloom High Maintenance Middle Purple Vivacious Pink Moonlit Mauve d787af
132 Dahlia Mauve Wild Mulberry Guppy Violet Meadow Mauve Cure All af5f87
205 Yíng Guāng Sè Pink Girls Night Out Pink Katydid Pink as Hell Hot Pink Fusion ff5faf
161 Tête-à-Tête Magna Cum Laude Rubine Red Anger Rowan d7005f
197 Flaming Hot Flamingoes Sizzling Watermelon New York Sunset Standby Led Sorx Red ff005f
211 Informative Pink Tickle Me Pink Strawberry Dreams Pinky Rock’n’Rose ff87af
168 Surfer Girl Blush d’Amour Flirty Rose Preppy Rose Groovy d75f87
204 Stellar Strawberry Brink Pink Warm Pink Ultra Red Rosy Pink ff5f87

Making an rgb.txt

The rgb.txt file format, as used by X11, is just one color per line, lower case name followed by #hex of the color. Optionally, lines starting with # are comments. Let’s make one for xterm. We’ll use the best match to name each color.

colors_by_name: dict[str, Color] = {
    name: color for color in colors for name in color.names[1:]
}

display_attachment(
    data="\n".join(
        f"{name.lower()}\t#{rgb_to_hex(color.channels)}"
        for name, color in colors_by_name.items()
    ),
    filename="rgb-xterm.txt",
)
rgb-xterm.txt

Let’s also make a second file that uses xterm indices instead of RGB colors - this is easier to use with commands like tput.

display_attachment(
    data="\n".join(
        f"{name.lower()}\t{color.names[0]}"
        for name, color in colors_by_name.items()
    ),
    filename="xterm-256.txt",
)
xterm-256.txt

Some other uses for a color database

There are some other neat things we can do, now that we have a color database. Interesting things become possible if we index by Hue-Luminance-Saturation (HLS) instead of Red-Green-Blue. Here’s what the color space looks like:

import colorsys

fig, axes = color_space_plot()
plot_color_dots(
    axes,
    0.1,
    ("Hue", "Lightness", "Saturation"),
    [(colorsys.rgb_to_hls(*color.channels), color) for color in medoai_colors],
)
Figure

And here’s how easy it is to rebuild the BVH. HLS is still three dimensions, so all we need to do is substitute a different 3-tuple instead of RGB.

Let’s use this to find some highly saturated shades of blue.

colors_by_hls = make_bvh(
    [
        (AABB.from_point(colorsys.rgb_to_hls(*color.channels)), color)
        for color in medoai_colors
    ]
)

lo_hls = (0.55, 0.55, 0.95)
hi_hls = (0.6, 0.6, 1.0)
results = list(colors_by_hls.search(AABB(lo_hls, hi_hls)))
display.HTML("\n".join(colors_html([color for _, color in results])))
Pool Water 2188ff
Fantasy Console Sky 29adff
Brilliant Azure 3399ff
Cherenkov Radiation 22bbff
Clear Chill 1e90ff

We can take a look at where these colors are located in the new BVH, as well:

fig, axes = color_space_plot()
plot_bvh_trace(axes, colors_by_hls, AABB(lo_hls, hi_hls))
plot_color_dots(
    axes,
    1.0,
    ("Hue", "Lightness", "Saturation"),
    [(bounds.lo, color) for bounds, color in results],
)
Figure

Useful Functions

All of this lets us define some neat utility functions for applications working with color.

class ColorDB:
    _by_hls: BVH
    _by_name: dict[str, Color]

    def __init__(self, colors: Iterable[Color]):
        self._by_name = {
            self._normalize_name(name): color
            for color in colors
            for name in color.names
        }
        self._by_hls = make_bvh(
            [
                (AABB.from_point(colorsys.rgb_to_hls(*color.channels)), color)
                for color in colors
            ]
        )

    def color_by_name(self, name: str) -> Color | None:
        return self._by_name.get(self._normalize_name(name))
    
    def similar_colors(self, color: Color, max_dist: float) -> Iterable[Color]:
        h, l, s = colorsys.rgb_to_hls(*color.channels)
        lo_hls = (h - max_dist, l - max_dist, s - max_dist)
        hi_hls = (h + max_dist, l + max_dist, s + max_dist)
        return (color for _, color in self._by_hls.search(AABB(lo_hls, hi_hls)))
    
    def shades_of(self, name: str, max_dist: float) -> Iterable[Color]:
        color = self.color_by_name(name)
        if color is None:
            return []
        # Find similar shades of grey by luminance, otherwise similar shades of
        # color by hue.
        h, l, s = colorsys.rgb_to_hls(*color.channels)
        if s < 0.1:
            lo_hls = (0, l - max_dist, 0)
            hi_hls = (1, l + max_dist, 1)
        else:
            lo_hls = (h - max_dist, 0.2, 0.1)
            hi_hls = (h + max_dist, 0.9, 1.0)

        return (color for _, color in self._by_hls.search(AABB(lo_hls, hi_hls)))

    @staticmethod
    def _normalize_name(name: str) -> str:
        return name.lower().strip()

For example, we can now query the XTerm color space for available shades of red:

db = ColorDB(colors)
results = db.shades_of("red", 0.05)
display.HTML("\n".join(colors_html(results)))
131 Italian Villa Poppy Prose Sienna Red Cinnamon Candle Spiced Tea af5f5f
95 Rabbit Paws Aged Beech Tarsier Forbidden Thrill Rose Garland 875f5f
138 Woodrose Orchid Red Warm Comfort Audrey’s Blush Retro Pink af8787
167 Roman Happy Hearts Salami Slice Deep Sea Coral Tory Red d75f5f
174 Peaches of Immortality Copperfield Rhubarb Pie Finest Blush Mauve Glow d78787
88 Chanticleer Glass Bull Sacrifice Altar Dark Red Scab Red 870000
124 Red Door Velvet Volcano Heartbeat Artful Red Red Pentacle af0000
160 Red Republic Rosso Corsa Hot Fever Red Pegasus Red Epiphyllum d70000
1 Maroon Salami Dark Red Sacrifice Altar Chanticleer 800000
209 After Burn Pink Fire Protein High Mango Orange Nectarine ff875f
9 Red Rainbow’s Outer Rim Fire Engine Encarnado Left on Red ff0000
203 Fusion Red Pineapple Salmon Pompelmo Pastel Red Grapefruit ff5f5f
196 Red Rainbow’s Outer Rim Fire Engine Encarnado Left on Red ff0000
181 Mary Rose Radiant Rouge Victoriana Pale Persimmon Ballet Rose d7afaf
210 Red Mull Coral Trails Tulip Prime Pink Camaron Pink ff8787
217 Fancy Flamingo Cornflower Lilac Wildflower Bouquet Peach Bud Apricot Haze ffafaf

Now isn’t that just neat?

Thanks for reading! You can download this article as a Jupyter Notebook.