Tous les articles

Recherche par similarité de couleur : cube, GiST et un convertisseur Lab fait main

27 juin 20267 min de lecture
Sur Wakfuli, une des fonctionnalités du filtre d'apparence (le module qu'on appelle en interne skinator) permet de trier un catalogue d'items par proximité de couleur : on donne une teinte cible, et on veut remonter les items dont une couleur dominante s'en rapproche le plus. Le premier réflexe, comparer les codes hex directement, ne fonctionne pas : deux hex numériquement proches peuvent être perçus comme très différents, et inversement. Le RGB n'est pas un espace perceptuellement uniforme : une distance euclidienne entre deux triplets RGB ne correspond à rien de stable pour l'œil humain.

Il fallait donc un espace de couleur où "distance numérique" et "différence perçue" coïncident à peu près, et un moyen de faire cette recherche sur un catalogue de plusieurs milliers d'items sans recalculer la distance de chacun à chaque requête.

Pourquoi Lab et pas RGB

L'espace CIE L*a*b* a été construit précisément pour ça, avec une échelle pensée pour que la distance euclidienne entre deux points corresponde approximativement à la différence perçue entre deux couleurs :

L
La luminance.
a, b
Les deux axes chromatiques.
ΔE (delta E)
Le nom de cette distance perceptuelle. Sa version la plus simple, CIE76, est juste la norme euclidienne classique dans l'espace Lab.
Rien de plus compliqué qu'un Pythagore en trois dimensions, mais dans le bon espace.

Ça change tout côté implémentation : si on stocke le Lab de chaque couleur, "trouve la couleur perceptuellement la plus proche" devient un problème de plus proche voisin dans un espace euclidien à trois dimensions, un problème que les bases de données géométriques savent résoudre efficacement.

Le pipeline de conversion

Convertir un hex en Lab n'est pas une formule à un coup, c'est une chaîne de trois transformations :

  1. 1

    sRGB to RGB linéaire

    L'espace dans lequel les hex sont exprimés, en retirant la correction gamma.
  2. 2

    RGB linéaire to XYZ

    Une projection vers un espace de référence indépendant du périphérique.
  3. 3

    XYZ to Lab

    Avec un blanc de référence, ici D65, le standard pour l'éclairage diurne.

Voici la partie centrale, écrite à la main dans l'ETL qui alimente le catalogue :

hexToLab.ts
ts
const D65 = { Xn: 95.047, Yn: 100.0, Zn: 108.883 }; 
 
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;
}
 
export function hexToLab(hex: string): [number, number, number] {
  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);
 
  // sRGB linéaire → XYZ (D65). Matrice tirée de 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];
}

Concrètement, un hex à six caractères se découpe en trois octets, un par canal :

Ce n'est pas de la magie mathématique, juste une suite d'étapes bien connues et documentées depuis des décennies (la matrice de passage RGB linéaire vers XYZ vient directement de la norme IEC 61966-2-1). L'important est de l'implémenter une seule fois, correctement, avec un test de non-régression, plutôt que d'invoquer une dépendance externe pour trois multiplications matricielles1.

  1. Le même fichier existe à l'identique côté application web, avec un commentaire qui dit explicitement pourquoi : les deux implémentations doivent produire des valeurs alignées avec ce qui a été précalculé et stocké en base.

Précalculer plutôt que recalculer

Le point de design le plus important n'est pas la formule de conversion, c'est le moment où elle s'exécute.

Le piège

La tentation naturelle serait de stocker les hex et de calculer la distance à la volée pour chaque requête de recherche. Ça marche pour dix items, ça s'effondre pour un catalogue qui grossit : chaque recherche deviendrait un calcul en mémoire applicative sur l'ensemble du catalogue, en O(n), répété à chaque appel.

À la place, le calcul se fait une seule fois, à l'ingestion. L'ETL convertit chaque hex en Lab et l'insère directement sous forme de type cube (l'extension PostgreSQL du même nom, pensée pour des points dans un espace à N dimensions) :

Schéma : colonne cube + index GiST
sql
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); 

Et l'insertion elle-même construit le cube directement depuis les trois composantes Lab calculées côté ETL :

Insertion : construction du cube à l'ingestion
sql
INSERT INTO skinator_item_colors(item_id, position, hex, lab)
VALUES ($1, $2, $3, cube(ARRAY[$4::float8, $5::float8, $6::float8])) 

Le travail lourd (la chaîne de conversion sRGB vers XYZ vers Lab) est fait une fois par couleur, hors du chemin critique, pas une fois par requête utilisateur.

Le choix d'index : cube et GiST

Stocker du Lab dans une colonne ne suffit pas à rendre la recherche rapide, il faut aussi que la base puisse chercher "les points les plus proches d'un point donné" sans parcourir toute la table. C'est exactement ce que fait l'index GiST (Generalized Search Tree) sur une colonne cube : il organise les points dans une structure arborescente qui permet d'élaguer la recherche géométriquement, comme un R-tree le ferait pour des coordonnées géographiques.

Une fois cet index en place, PostgreSQL expose un opérateur de distance, <->, entre deux cubes. Voici la requête qui l'utilise, en sous-requête corrélée pour prendre la couleur dominante la plus proche parmi les swatches d'un item :

applyColorOrdering
ts
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') 
    .orderBy('id', 'desc');
}

Pour chaque item, la sous-requête calcule la distance minimale entre ses couleurs et la couleur cible, et le tri se fait sur cette colonne calculée.

Sans l'index

Chaque exécution de c.lab <-> cube(...) déclenche un balayage complet de skinator_item_colors.

Avec l'index GiST

PostgreSQL descend directement vers les cubes les plus proches du point cible : la recherche de proximité devient un lookup d'index spatial, pas un calcul en O(n) répété à chaque swatch de chaque item.

La leçon : indexer la bonne chose

À retenir

Ce qui rend cette fonctionnalité tenable à l'échelle n'est ni la formule Lab (connue depuis longtemps) ni l'opérateur <-> (une ligne de SQL), mais l'endroit où chaque calcul a été placé. Le calcul coûteux et répétitif, la conversion perceptuelle, se fait une fois par couleur à l'ingestion. Le calcul qui doit rester rapide à chaque requête utilisateur, la recherche du plus proche voisin, est délégué à une structure de données du SGBD conçue pour ça.

La question à se poser n'est pas seulement "quel algorithme utiliser" mais quelle donnée mérite d'être indexée, et sous quelle forme. Ici, la réponse était de sortir la couleur de son espace natif (RGB, hex) pour la faire vivre dans un espace où la géométrie a un sens, puis de laisser un index spatial faire le travail qu'aucune boucle applicative ne ferait aussi bien.