Un design haut en couleur
2015-03-04Il y a quelques semaines, j’ai publié la version 1.0.0-alpha de ma
MCL et j’ai mis à jour la page
Projets de ce site en conséquence, ignorant
superbement au passage ma bonne résolution : terminer les projets
existants plutôt que d’en commencer de nouveaux. Pour être honnête, ce
petit projet n’est pas de la plus haute importance, et j’en resterai
probablement son seul utilisateur, mais pour citer ce que j’avais dit au
moment de la publication de SAW :
Ce n’est pas grand chose, d’abord et surtout parce que personne ne l’utilise à part moi, ce qui fait que je n’ai pas de problèmes à résoudre, pas de bugs bizarres que je n’arrive pas à reproduire chez moi… Mais pour autant, la publier n’était pas vide de sens.
Et donc, parlons de couleurs, de design, de C++ et de build systems ! Cet article se focalise sur certains aspects techniques de la MCL, sans ordre particulier. Un probable prochain article s’occupera de présenter quelques anecdotes amusantes et illustrées au sujet de l’interpolation de couleurs.
Il était une fois…
…un passionnant article (en anglais) sur Hacker News. Son sujet : la génération procédurale de couleurs, et l’interpolation de couleurs dans l’espace de couleur CIE LCHab. Il se trouve que j’avais déjà implémenté, longtemps avant, une très rudimentaire classe de gestion de couleur, gérant RGB et HSV, que j’utilisais pour faire le même genre d’interpolation. Cette vieille classe était très limitée, principalement par le fait qu’elle stockait en permanence tous les champs des deux espaces de couleur (R, G, B, H, S, V). En conséquence :
- cette classe
Color
était relativement lourde, - elle n’était pas facilement extensible à de nouveaux espaces de couleur,
- elle n’était pas efficace (le moindre changement forçait à recalculer tous les champs impactés).
J’avais en conséquence, dans mon code, plusieurs classes de couleur différentes en parallèle : celle susmentionnée pour l’interpolation, que je traduisais en une autre plus légère adaptée à du code OpenGL… Peu pratique. Inspiré par cet article, je me suis lancé à corps perdu dans l’implémentation d’une nouvelle classe de couleur, commençant ce qui est devenu la MCL.
Objectifs
Avant même de me lancer dans le projet, j’avais déjà en tête plusieurs buts que je voulais clairement atteindre. Même si certaines des features amusantes sont apparues au fur et à mesure du développement, celles qui suivent étaient des objectifs dès le lancement du projet.
Une classe RGB minimaliste
Premier objectif : avoir une struct RGB minimaliste, que je puisse
caster en double*
, en float*
, ou en unsigned char*
selon le type
de représentation choisi. Avoir une telle classe me permettrait de
passer directement des tableaux d’instances RGB à OpenGL, par
exemple.
Gérer l’espace de couleur LCHab
En plus de gérer les espaces de couleur que je connaissais déjà (RGB, HSL, HSV), je voulais pouvoir manipuler des couleurs en LCHab, l’espace de couleur loué dans l’article susmentionné.
Colormaps
Ma vieille classe de couleur était accompagnée d’une implémentation assez simple d’une colormap, permettant l’interpolation linéaire entre plusieurs couleurs. Étant la feature la plus utilisée de mon ancien code, il était impensable de ne pas l’adapter à cette nouvelle bibliothèque.
C++11
Bien que ce ne soit pas exactement un objectif en tant que tel, j’ai utilisé l’excuse du C++11 pour me convaincre que commencer un nouveau projet pouvait être une bonne idée : tous mes autres projets étant encore en C++03, je n’avais encore jamais écrit de code utilisant les nouvelles features de C++11.
Types algébriques > héritage
En raison de mon premier objectif, il n’était pas possible d’avoir une classe différente par espace de couleur, héritant chacune d’une classe abstraite Color. La raison, bien sûr, la vtable. Pas d’héritage, pas de vtable, en conséquence de quoi la taille de la classe devrait être égale à la somme de la taille de ses composants, s’ils sont correctement alignés. Le standard n’autorisant pas le compilateur à modifier l’ordre des membres, une classe minimaliste de ce type devrait respecter mon premier objectif.
Il est par contre important de noter que le fait de dépendre du fait que les compilateurs aligneront correctement les données en mémoire sans rien ajouter autour est un peu moche : rien ne les y oblige, ces classes n’étant pas des “PODS”. Bien qu’en pratique ce soit bien le cas pour tous les compilateurs testés, en dépendre pour faire les casts moches susmentionnés (pour OpenGL par exemple), c’est mettre le pied dans les territoires démoniaques des comportements non-spécifiés. Fun stuff. :)
La meilleure solution pour avoir une classe Color générique est
d’utiliser des outils de la programmation fonctionnelle ; ici,
nommément, les
types de données algébriques. Comme
il n’existe pas en C++ de syntaxe pour écrire un ADT (non, les unions ne
comptent pas), j’ai invoqué le pouvoir du tout-puissant
boost::variant
, qui permet la
création de pseudo “unions typées”.
Comparé à une hiérarchie classique, un type de ce genre est à l’opposée en ce qui concerne le fameux Expression Problem: autant il devient fastidieux d’ajouter de nouveaux espaces de couleurs, de nouveaux types (ce qui est en l’occurrence, peu probable), ajouter de nouvelles fonctions sur les couleurs est facile, et se fait de manière générique sans avoir besoin d’aller modifier les classes existantes. Et surtout, il permet de manipuler des couleurs de manière polymorphique, sur la stack, sans pointeur.
Graphe de référence
Une façon naïve mais fastidieuse d’implémenter les conversions entre les différents espaces de couleur serait d’implémenter chacune des N * N fonctions. La MCL prend plutôt le parti de diviser les espaces de couleurs en trois groupes.
- Écran : RGB, HSL, HSV.
- Impression : CMYK, CMY.
- Indépendant : XYZ, LAB, LCH.
Chaque groupe a un type de référence (mis en évidence ci-dessus),
déclaré en dur via un typedef
. Convertir d’un espace de couleur à un
autre revient donc à suivre les arêtes du graphe ci-dessous, ce qui
limite de manière drastique le nombre de fonctions à implémenter. Qui
plus est, s’il faut un jour ajouter un nouvel espace de couleur, il ne
sera nécessaire d’implémenter que les fonctions de conversion vers et
depuis son type de référence.
Cette répartition n’est pas le fruit du hasard. Ces trois groupes
exhibent une propriété intéressante : les conversions au sein d’un
groupe ne dépendent d’aucun paramètre externe tel un
point blanc (sauf dans un
cas précis : XYZ <-> LAB). Seules les conversions d’un groupe à un
autre les nécessitent. Tous les paramètres nécessaires sont rassemblées
dans la classe Environment
.
Toutes les fonctions de la MCL qui, à un moment, font des conversions
entre espaces de couleur, prennent en paramètre une variable de type
Environment
. Par soucis de praticité, chacune admet une variante sans
le paramètre utilisant la variable Environment::DEFAULT
. Cellle-ci est
faite de manière à ce que tout traitement soit effectué dans un
environnement sRGB avec un
standard illuminant
D65. L’utilisateur souhaitant
personnaliser ce comportement peut soit passer une instance
personnalisée d’Environnement
à chaque appel, ou plus simplement (mais
moins proprement) remplacer Environment::DEFAULT
, qui est mutable.
Cela permet par ailleurs d’injecter d’autres fonctions de conversion, potentiellement bien plus complexes, dans la MCL, telles les fonctions de LittleCMS permettant l’utilisation de profiles ICC.
Composition monoïdale
La plupart des fonctions de transformation de couleur peuvent s’exprimer
sous la forme
d’endomorphismes :
leur type est, schématiquement, Color -> Color
. Elles forment du coup
un
monoïde,
à condition d’implémenter les équivalents de mempty
et de mappend
,
qui seraient ici plus justement nommées id
et compose
.
Alors qu’en Haskell, c’est relativement trivial,
en C++, c’est une autre paire de manches…
Ces propriétés permettent quelques trucs rigolos, comme le fait de
combiner des transformations, ou même de folder des transformations :
une liste de transformations peut être réduite à une seule. Et ces
propriétés restent vraies même avec des fonctions de transformation de
Color
définies par l’utilisateur, tant qu’elles sont implicitement
convertibles en Endomorphism
, défini comme std::function<T (T
const&)>
.
Bien que tout ceci ne faisait pas du tout partie des objectifs initiaux, le résultat est… esthétiquement satisfaisant. C’était également l’occasion de chercher une manière d’implémenter des typeclasses à la Haskell en C++, ce pour quoi j’ai trouvé une solution à base de traits amusante à défaut d’être très utile. J’en parlerai plus en détail dans un prochain article.
Pour résumer
Autant je suis très fier du résultat, autant cette petite bibliothèque a des limitations sérieuses.
- Le build system est l’ideux que j’ai écrit moi-même en 2008…
- La seule fonction fournie pour clamper les couleurs hors gamut est très naïve.
- Je n’ai fait aucune analyse poussée de la performance du code (intuitivement, monter la limite d’inlining devrait avoir un effet non négligeable).
- Elle manque drastiquement de feedback de vrais utilisateurs !
Si vous voulez en savoir plus sur le fonctionnement technique de la MCL, le wiki du projet détaille chaque feature une par une.
Pour résumer et conclure : c’était rigolo. :)