Aller au contenu

Du pixel art avec Octave : des matrices, des couleurs et un peu de tramage

Par Basile Marchand

Note ·

Transformer une photo en pixel art avec GNU Octave : moyennes par blocs, palette K-means et diffusion d’erreur inspirée de Floyd–Steinberg.

Développement scientifique

Pour me refaire un peu la main sur Octave, je me suis amusé à transformer une photo en pixel art. Le principe est assez simple : faire de gros pixels et réduire le nombre de couleurs. Et comme on est dans Octave, autant faire ça avec quelques produits de matrices 😎.

Voilà le genre de résultat que ça donne :

Vue urbaine transformée en pixel art avec des blocs de 15 pixels et une palette de 16 couleurs.

L’ensemble du code est disponible sur GitHub. Ici je vais surtout vous expliquer les quelques opérations derrière.

Voici la photo de départ :

Photo de départ : des immeubles sous un ciel bleu.

Une image RGB, c’est un tableau avec trois canaux : rouge, vert et bleu. On peut donc travailler sur chaque canal comme sur une matrice. Je commence par convertir l’image avec im2double, pour avoir des valeurs entre 0 et 1.

Ensuite, l’idée est de prendre des blocs de pixels et de remplacer chaque bloc par sa couleur moyenne. Si je prends des blocs de 15 × 15, mes 225 pixels deviennent un seul pixel. On perd les petits détails et on garde les grandes zones de couleur.

Pour faire ça, j’ai construit deux projecteurs avec un produit de Kronecker :

width_r = floor(width / ratio);
height_r = floor(height / ratio);
im_crop = im(1:height_r*ratio, 1:width_r*ratio, :);
projector_left = kron(speye(height_r), ones(1, ratio) / ratio);
projector_right = kron(speye(width_r), ones(1, ratio) / ratio);
for c = 1:channels
im_r(:,:,c) = projector_left * im_crop(:,:,c) * projector_right';
endfor

Alors si kron ne vous parle pas, ici il sert à répéter le même petit bloc de moyenne le long de la matrice. speye nous donne une matrice identité creuse, et ones(1, ratio) / ratio fait la moyenne de ratio valeurs.

Le projecteur de gauche regroupe les lignes, celui de droite les colonnes. Avec les deux multiplications, on a donc notre moyenne sur chaque carré. On recadre juste l’image au début pour avoir un nombre entier de blocs.

À ce stade on a une petite image, mais encore beaucoup de couleurs différentes. Pour obtenir une palette de 8, 16 ou 32 couleurs, j’utilise K-means.

On range les pixels dans une matrice où chaque ligne contient les trois valeurs RGB d’un pixel, puis on demande à K-means de les répartir en ncolors groupes :

X = reshape(im_r, [], 3);
[idx, palette] = kmeans(X, ncolors, ...
"Start", "plus", ...
"Replicates", 5);

Les centres des groupes nous donnent les couleurs de la palette. Donc pas besoin de choisir à la main les couleurs qui vont bien avec la photo, c’est le calcul qui s’en charge.

Pour voir ce que ça change, voilà le résultat avec des blocs de 15 × 15 :

2 couleurs8 couleurs32 couleurs
Résultat avec une palette de 2 couleurs.Résultat avec une palette de 8 couleurs.Résultat avec une palette de 32 couleurs.

Avec deux couleurs on est assez radical ! En augmentant la palette on retrouve progressivement les nuances du ciel et des bâtiments.

Une fois la palette calculée, on pourrait simplement remplacer chaque pixel par la couleur la plus proche. Mais dans les dégradés, notamment le ciel ici, on se retrouve vite avec de grandes bandes de couleur.

J’ai donc ajouté un tramage inspiré de Floyd–Steinberg. Le principe : quand on remplace un pixel par une couleur de la palette, on calcule l’écart et on en transmet une partie aux pixels voisins. Ils vont en tenir compte au moment de choisir leur propre couleur.

Pour choisir la couleur, on fait tout simplement une recherche du plus proche voisin dans notre palette RGB :

distances = sum((palette - pixel).^2, 2);
[~, k] = min(distances);
color = palette(k,:);
error = 0.3 * (pixel - color);

Dans cette version je diffuse 30 % de l’erreur, répartis entre le pixel de droite et les trois voisins de la ligne suivante :

À gaucheMême colonneÀ droite
—Pixel courant7/16
3/165/161/16

On parcourt l’image de gauche à droite et de haut en bas. L’erreur est donc toujours envoyée à des pixels que l’on n’a pas encore traités. C’est ce qui donne les petits motifs visibles dans les transitions entre deux couleurs.

Il reste à agrandir notre petite image. Ici on utilise le plus proche voisin pour garder des blocs bien nets :

im_pix = imresize(im_d, ratio, "nearest");

Chaque pixel redevient un carré de ratio × ratio pixels, avec la couleur choisie à l’étape précédente. Le tramage a été fait sur la petite image, donc ses motifs sont agrandis eux aussi.

Avec 16 couleurs, voilà ce que donne le changement de taille des blocs :

Blocs de 5 × 5Blocs de 15 × 15Blocs de 30 × 30
Résultat avec des blocs de 5 pixels.Résultat avec des blocs de 15 pixels.Résultat avec des blocs de 30 pixels.

Il vous faudra Octave et les paquets image et statistics. Le script main.m lit une photo RGB nommée image.jpg et génère les variantes dans outputs/.

Les deux boucles permettent de faire varier le nombre de couleurs et la taille des blocs :

for color = [2,4,8,16,32]
for ratio = [5,10,15,20,25,30]
im_pixel = pixel_art(im_int, ratio, color);
output_filepath = fullfile(folder, ...
sprintf("image_%d_%d.jpg", ratio, color));
imwrite(im_pixel, output_filepath);
endfor
endfor

Ça fait 30 variantes pour regarder tranquillement ce qui vous plaît. Vous pouvez aussi jouer avec le coefficient 0.3 dans floyd_steinberg.m pour changer la quantité d’erreur diffusée.

Le dépôt contient les images et les deux fonctions si vous voulez essayer avec vos propres photos 🤗.

Ressources associées

À lire aussi