---
title: "Color-similarity search: cube, GiST, and a hand-rolled Lab converter"
description: "On Wakfuli, one of the features of the appearance filter (the module we call \"skinator\" internally) lets you sort an item catalog by color proximity: you give…"
date: "2026-06-27T09:00:00.000Z"
updated: "2026-08-24T15:05:22.420Z"
locale: "en"
canonical: "https://ninhache.fr/en/blog/recherche-couleur-lab-gist"
author: "Néo Almeida"
tags: "postgres, gist, couleur"
categories: "dev"
---

# Color-similarity search: cube, GiST, and a hand-rolled Lab converter

On Wakfuli, one of the features of the appearance filter (the module we internally call skinator) lets you sort an item catalog by color proximity: you give a target hue, and you want to surface the items whose dominant color is closest to it. The first instinct, comparing hex codes directly, doesn't work: two numerically close hex values can be perceived as very different, and vice versa. RGB is not a perceptually uniform space: a Euclidean distance between two RGB triplets doesn't correspond to anything stable for the human eye.

So we needed a color space where "numeric distance" and "perceived difference" roughly line up, and a way to run that search on a catalog of several thousand items without recomputing the distance to every one of them on every query.

## Why Lab and not RGB

The CIE L\*a\*b\* space was built precisely for that, with a scale designed so that the Euclidean distance between two points roughly matches the perceived difference between two colors:

**L**

Luminance.

**a, b**

The two chromatic axes.

**ΔE (delta E)**

The name of this perceptual distance. Its simplest version, CIE76, is just the classic Euclidean norm in Lab space.

Nothing more complicated than a three-dimensional Pythagorean theorem, just in the right space.

This changes everything on the implementation side: if we store the Lab value of each color, "find the perceptually closest color" becomes a nearest-neighbor problem in a three-dimensional Euclidean space, a problem that geometric databases know how to solve efficiently.

## The conversion pipeline

Converting a hex to Lab isn't a one-shot formula

, it's a chain of three transformations:

**sRGB -> linear RGB**

The space in which hex values are expressed, removing the gamma correction.

**Linear RGB -> XYZ**

A projection into a device-independent reference space.

**XYZ -> Lab**

Using a reference white point, here D65, the standard for daylight illumination.

Here's the core piece, hand-written in the ETL that feeds the catalog:

```ts title="hexToLab.ts"
const D65 = { Xn: 95.047, Yn: 100.0, Zn: 108.883 }; // [!code highlight]

function srgbToLinear(c: number): number {
  return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
}

function fxyz(t: number): number {
  return t > 216 / 24389 ? Math.cbrt(t) : (24389 / 27 * t + 16) / 116;
}
  const r8 = parseInt(hex.slice(0, 2), 16);
  const g8 = parseInt(hex.slice(2, 4), 16);
  const b8 = parseInt(hex.slice(4, 6), 16);

  const r = srgbToLinear(r8 / 255);
  const g = srgbToLinear(g8 / 255);
  const b = srgbToLinear(b8 / 255);

  // Linear sRGB → XYZ (D65). Matrix taken from IEC 61966-2-1.
  const X = (r * 0.4124564 + g * 0.3575761 + b * 0.1804375) * 100;
  const Y = (r * 0.2126729 + g * 0.7151522 + b * 0.0721750) * 100;
  const Z = (r * 0.0193339 + g * 0.1191920 + b * 0.9503041) * 100;

  const fx = fxyz(X / D65.Xn);
  const fy = fxyz(Y / D65.Yn);
  const fz = fxyz(Z / D65.Zn);

  const L = 116 * fy - 16;
  const a = 500 * (fx - fy);
  const bb = 200 * (fy - fz);
  return [L, a, bb];
}
```

Concretely, a six-character hex splits into three bytes, one per channel:

This isn't mathematical magic, just a sequence of steps that have been well known and documented for decades (the linear-RGB-to-XYZ conversion matrix comes straight from the IEC 61966-2-1 standard). What matters is implementing it once, correctly, with a regression test, rather than pulling in an external dependency for three matrix multiplications (The same file exists identically on the web app side, with a comment that explicitly says why: both implementations must produce values aligned with what was precomputed and stored in the database.).

## Precompute rather than recompute

The most important design decision isn't the conversion formula, it's when it runs.

**Piège**

The natural temptation would be to store the hex values and compute the distance on the fly for every search query. That works for ten items, but it collapses as the catalog grows: every search would turn into an in-memory, application-level computation over the whole catalog, in O(n), repeated on every call.

Instead, the computation happens once, at ingestion time. The ETL converts each hex to Lab and inserts it directly as a `cube` type (the PostgreSQL extension of the same name, built for points in an N-dimensional space):

```sql title="Schema: cube column + GiST index"
CREATE EXTENSION IF NOT EXISTS cube;

CREATE TABLE skinator_item_colors (
  item_id   INTEGER   NOT NULL REFERENCES skinator_items(id) ON DELETE CASCADE,
  position  SMALLINT  NOT NULL,
  hex       CHAR(6)   NOT NULL,
  lab       CUBE      NOT NULL,
  PRIMARY KEY (item_id, position)
);

CREATE INDEX idx_skinator_item_colors_lab_gist
  ON skinator_item_colors USING GIST (lab); -- [!code highlight]
```

And the insert itself builds the cube directly from the three Lab components computed on the ETL side:

```sql title="Insert: building the cube at ingestion time"
INSERT INTO skinator_item_colors(item_id, position, hex, lab)
VALUES ($1, $2, $3, cube(ARRAY[$4::float8, $5::float8, $6::float8])) -- [!code highlight]
```

The heavy lifting (the sRGB-to-XYZ-to-Lab conversion chain) happens once per color, outside the critical path, not once per user request.

## The index choice: cube and GiST

Storing Lab values in a column isn't enough to make the search fast; the database also needs to be able to find "the points closest to a given point" without scanning the whole table. That's exactly what the GiST (Generalized Search Tree) index on a `cube` column does: it organizes the points into a tree structure that lets it prune the search geometrically, the same way an R-tree would for geographic coordinates.

Once this index is in place, PostgreSQL exposes a distance operator, `<->`, between two cubes. Here's the query that uses it, as a correlated subquery, to grab the closest dominant color among an item's swatches:

```ts title="applyColorOrdering"
applyColorOrdering(query: Query, lab: [number, number, number]): Query {
  const [l, a, b] = lab;
  return query
    .select('skinator_items.*')
    .select(
      db.raw(
        `(
          SELECT MIN(c.lab <-> cube(ARRAY[?::float8, ?::float8, ?::float8]))
            FROM skinator_item_colors c
           WHERE c.item_id = skinator_items.id
        ) AS delta`,
        [l, a, b],
      ),
    )
    .orderByRaw('delta ASC NULLS LAST') // [!code highlight]
    .orderBy('id', 'desc');
}
```

For each item, the subquery computes the minimum distance between its colors and the target color, and the sort happens on this computed column.

**Without the index**

Every execution of `c.lab <-> cube(...)` triggers a full scan of `skinator_item_colors`.

**With the GiST index**

PostgreSQL goes straight to the cubes closest to the target point: the proximity search becomes a spatial index lookup, not an O(n) computation repeated for every swatch of every item.

## The lesson: index the right thing

**Leçon**

What makes this feature sustainable at scale is neither the Lab formula (known for a long time) nor the `<->` operator (a single line of SQL), but where each computation was placed. The expensive, repetitive computation, the perceptual conversion, happens once per color at ingestion. The computation that has to stay fast on every user request, the nearest-neighbor search, is delegated to a database data structure built exactly for that.

The question to ask isn't just "which algorithm to use" but which data deserves to be indexed, and in what form. Here, the answer was to take the color out of its native space (RGB, hex) and let it live in a space where geometry means something, then let a spatial index do the work no application-level loop would do as well.
