Aller au contenu
BackSQLPostgresqlCréationDatabaseDIYArt

Découpage de pixels chez PixelHobby

Dans cet article, nous mettons en pratique plusieurs techniques, dont pas mal de SQL, pour gérer une grande oeuvre faite avec des pixels de chez PixelHobby.

Axolotl en Pixel Art.

Introduction

Connaissez-vous PixelHobby ? C'est une entreprise néerlandaise qui vous propose de réaliser des oeuvres d'art à partir de petits blocs (~1mm cube) de plastique. Pour ceux qui s'inquiéteraient, "Pixel worden duurzaam geproduceerd uit bio-plastics en zijn 100% recyclebaar", autrement dit ces pixels sont faits à partir de bio-plastique 100% recyclable !

Voici à quoi ressemblaient nos faire-parts de mariage à mon épouse et moi :

Heureusement, nous nous sommes mariés en plein Covid et n'avions droit en Belgique qu'à une dizaine d'invités 😅

Car ce hobby est plutôt consommateur en temps. Il existe des modèles tout faits, mais la force de PixelHobby est de pouvoir créer vos propres patrons à partir de n'importe quelle image. Ce que vous voyez ci-dessus a été fait à partir d'une photo personnelle. Bien sûr la qualité est assez faible et vous pourriez vouloir une résolution plus grande. PixelHobby a tout prévu : Il est possible d'assembler les plaques à l'aide de connecteurs. L'idée est donc d'aller sur leur site web, d'uploader une photo, de choisir vos paramètres (notamment le nombre de plaques et le nombre de pixels d'une couleur minimum pour que la couleur apparaisse dans le modèle). L'application génère un JSON qui sera envoyé au site marchand de votre choix pour que vous puissiez passer votre commande.

Sauf que si vous êtes un minimum maso et/ou avez la folie des grandeurs, un jour ou l'autre vous vous déciderez à faire un modèle beaucoup trop grand, par exemple de 9 * 11 plaques, soit 99 plaques à 2000 pixels par plaque, donc 198 000 pixels au moins à commander (vu que vous aurez certainement des restes dans chaque couleur).

Plusieurs problèmes se posent :

  • Ça coûte cher, commander tout ça d'un coup, ce n'est pas forcément évident
  • Ça prend de la place
  • Les frais de port sont de toute façon offerts à partir de 50€ (argument purement mercantile qui fera bondir n'importe quel âme écologiste, j'assume cet écart)

Le site web de PixelHobby ne propose pas de faire l'achat en plusieurs parties. Cet article vous propose d'exploiter le fichier JSON (qui a l'extension phd, sans doute pour pixel hobby data), afin de faire ce "découpage" nous-mêmes.

Définitions

  • Un pixel est une pièce de plastique qui compose le dessin final
  • Une plaque accueille des pixels. Une plaque standard a 50 colonnes de 40 pixels, soit 2000 pixels.
  • PixelHobby propose un certain nombre de couleurs, numérotées de 100 à 999. Certains nombres ne correspondent à aucune couleur.
  • Un module est un ensemble de 140 pixels d'une couleur donnée.
  • Un connecteur permet d'accrocher plusieurs plaques entre elles. Il faut 1 ou 2 connecteurs pour attacher deux plaques entre elles.
  • Le fichier PHD (Pixel Hobby Data) et le manuel sont deux fichiers aux formats JSON et PDF respectivement générés par le site web de PixelHobby à partir d'une image
  • Un modèle est composé de plusieurs plaques de pixels, avec les couleurs définies pour chaque pixel.

Structure du fichier PHD

Le fichier JSON contient pas mal d'informations, notamment l'image d'origine encodée en base64. Je ne vais pas décrire tout le fichier. Ce qui nous intéresse, c'est le tableau `__PHC_ARRAY` qui contient la couleur de chaque pixel ainsi que l'objet `__RESOLUTION { width, height }` qui donne la taille (en pixels) du dessin final.

La base de données

Je suis un convaincu de l'utilité des bases de données pour gérer et exploiter les données en plus de les stocker. Voici donc quelques tables, puis quelques vues, qui permettent de résoudre différentes problématiques en rapport avec les pixels. On pourrait imaginer ajouter une interface graphique ensuite, par exemple avec PostgREST et n'importe quel technologie Front, mais je ne l'ai pas fait et ne compte pas en parler dans cet article 😉

Le dialecte que j'utilise est PostgreSQL mais vous pouvez facilement adapter les requêtes à n'importe quel SGBDR.

La table phd

On commence doucement avec une table qui contiendra les fichiers phd de nos différents modèles. Rien de particulier ici, pour info mon fichier d'origine fait un peu plus de 8MiB et l'insertion s'exécute en un peu plus d'une seconde.

CREATE TABLE phd (
    content jsonb NOT NULL
);

La table plate_type

Il existe plusieurs types de plaques, notamment les carrés de 24 * 24 pixels et les plaques standard de 40 * 50 pixels. Il existe également des médaillons de 14 * 11 pixels et PixelHobby pourrait sortir d'autres formats dans le futur.

CREATE TABLE plate_type (
  plate_type VARCHAR(50) NOT NULL,
  orientation VARCHAR(50) NOT NULL,
  PRIMARY KEY (plate_type, orientation),

  single_plate_width INT NOT NULL,
  single_plate_height INT NOT NULL
);

INSERT INTO plate_type (plate_type, orientation, single_plate_width, single_plate_height) VALUES
('square 24x24', 'horizontal', 24, 24),
('square 24x24', 'vertical', 24, 24),
('standard 50x40', 'horizontal', 50, 40),
('standard 50x40', 'vertical', 40, 50);

La table model et la vue v_model

Un modèle a un nom, un type de plaque (clé étrangère vers la table précédente) et un nombre de plaques par ligne et par colonne.

La vue `v_model` ajoute les infos liées au type de plaque ainsi que la taille totale du modèle en termes de nombre de pixels.

CREATE TABLE model (
  id SERIAL NOT NULL PRIMARY KEY,
  name VARCHAR(50) NOT NULL UNIQUE,

  plate_type VARCHAR(50) NOT NULL DEFAULT 'standard 50x40',
  orientation VARCHAR(50) NOT NULL DEFAULT 'horizontal',
  FOREIGN KEY (plate_type, orientation) REFERENCES plate_type,

  plate_width INT NOT NULL, -- Number of plates per row
  plate_height INT NOT NULL -- Number of plates per column
);

CREATE VIEW v_model AS
SELECT
  id, name, plate_type, orientation, plate_width, plate_height,
  single_plate_width, single_plate_height,
  (single_plate_width * plate_width) AS model_width,
  (single_plate_height * plate_height) AS model_height
FROM model
NATURAL JOIN plate_type;
id 1
name photo famille 2022
plate_type standard 50x40
orientation horizontal
plate_width 9
plate_height 11
single_plate_width 50
single_plate_height 40
model_width 450
model_height 440

La table pixel

Pour finir, la table `pixel` complète l'information associée au modèle en déterminant la couleur de chaque pixel du modèle.

La colonne `x` donne la colonne du pixel (la première colonne étant numérotée 0) et la colonne `y` sa ligne (idem).

CREATE TABLE pixel (
  model_id INT NOT NULL REFERENCES model,
  x INT NOT NULL CHECK (x >= 0),
  y INT NOT NULL CHECK (y >= 0),
  PRIMARY KEY (model_id, x, y),

  color INT NOT NULL CHECK (color BETWEEN 100 AND 999)
);

Insérer les données

Le fichier PHD contient la hauteur et la largeur en nombre de pixels, mais j'avoue une certaine forme de fainéantise ici, et j'ai hardcodé ces valeurs pour cette fois.

DO
LANGUAGE plpgsql
$$
DECLARE
    plate_width INT = 9;
    plate_height INT = 11;

    id INT;
    width INT = 50 * plate_width;
BEGIN
    INSERT INTO model (name, plate_width, plate_height)
    VALUES ('photo famille 2022', plate_width, plate_height)
    RETURNING model.id INTO id;

    WITH T(color_id) AS (
        SELECT jsonb_array_elements(content->'__PHC_ARRAY')
        FROM phd
    )
    INSERT INTO pixel (model_id, x, y, color)
    SELECT id, (row_number() OVER () - 1) % width AS x, (row_number() OVER () - 1) / width AS y, (color_id->>0)::int
    FROM T;
END
$$;

Comme vous le voyez, on ne fait qu'insérer les couleurs dans la table pixel. Le schéma du JSON est proche de celui de la base de données, du coup pas de grosse difficulté ici, à part peut-être la syntaxe associée à l'exploitation du JSON, je vous laisse regarder la doc si ça vous intéresse (pour rappel le type de donnée est jsonb). La commande DO est un raccourci qui permet d'exécuter du code de type pl/pgsql sans avoir à créer une fonction.

Couleurs sur chaque plaque

Si vous faites des pixels en équipe (de deux ou plus), il y aura probablement cette guéguerre de savoir qui est le plus avancé sur sa plaque qui s'installera. C'est très difficile à dire en regardant juste la plaque. Par contre, on sait en général exactement à quelle couleur on en est et la vue matérialisée `mv_plate_color` vous permettra de savoir où vous en êtes par rapport aux autres membres de l'équipe 😀

Connaissez-vous le concept de vue matérialisée ? Il s'agit d'une table basée sur une requête, comme une vue, mais qu'on met à jour manuellement, et dont les données sont donc persistées sur le disque dur.

Voici la requête :

CREATE MATERIALIZED VIEW mv_plate_color (model_id, plate_y, plate_x, color, amount) AS
WITH T (model_id, x, y, plate_y, plate_x, color) AS (
    SELECT
        pixel.model_id, pixel.x, pixel.y,
        (pixel.y / v_model.single_plate_height) AS plate_y,
        (pixel.x / v_model.single_plate_width) AS plate_x,
        pixel.color
    FROM v_model
    INNER JOIN pixel ON v_model.id = pixel.model_id
)
SELECT model_id, plate_y, plate_x, color, COUNT(*)
FROM T
GROUP BY model_id, plate_y, plate_x, color;

La CTE ajoute juste les coordonnées de la plaque à chaque pixel, que nous appellerons toujours `plate_x` et `plate_y`, par opposition à `x` et `y` qui correspondent toujours aux coordonnées d'un pixel par rapport à tout le modèle. Nous pouvons ensuite grouper par plaque et compter le nombre de pixels de chaque couleur.

La première ligne précise qu'il s'agit d'une vue matérialisée. Chaque fois que nous mettrons à jour les données, il faudra faire `REFRESH MATERIALIZED VIEW mv_plate_color;` afin de mettre à jour les données de `mv_plate_color`.

model_id plate_y plate_x color amount
1 0 0 173 425
1 0 0 185 1456
1 0 0 411 119
1 0 1 104 6
... ... ... ... ...

On voit ici que la première plaque n'est composée que des couleurs 173, 185 et 411, la couleur 185 à elle seule demande 1456 pixels ! Pour ne rien vous cacher, faire les plaques du haut en général était plutôt monotone 😮‍💨

Pages à imprimer pour chaque plaque

Les plaques sont transparentes. Lorsque vous créez votre modèle, vous recevez un PDF à imprimer avec les instructions pour construire votre oeuvre, pixel par pixel. Chaque page du manuel contient deux représentations de la plaque à taille réelle, avec quatre symboles différents et des couleurs associées à chaque symbole, comme montré ci-dessous.

Pour une grosse oeuvre, le manuel peut atteindre 800 pages ! Tout imprimer d'un coup n'a pas beaucoup de sens, surtout si on n'a acheté que de quoi faire les 10 premières plaques, par exemple. Cette vue donne pour chaque plaque la première et la dernière page à imprimer, ce qui permet de savoir facilement jusque où imprimer. Par ailleurs, on peut du coup imprimer recto verso et imprimer plaque par plaque pour ne pas avoir deux plaques différentes sur la même feuille de papier.

CREATE MATERIALIZED VIEW mv_plate_pages (model_id, plate_y, plate_x, colors, page_count, page_start, page_end) AS
WITH T AS (
    SELECT model_id, plate_y, plate_x, COUNT(*) AS colors, ceil(COUNT(*) / 8.0) AS page_count
    FROM mv_plate_color
    GROUP BY model_id, plate_y, plate_x
)
SELECT model_id, plate_y, plate_x, colors, page_count,
       2 + coalesce(SUM(page_count) OVER (ORDER BY plate_y, plate_x ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING), 0) AS page_start,
       1 + coalesce(SUM(page_count) OVER (ORDER BY plate_y, plate_x ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW), 0) AS page_end
FROM T;

Pas mal de choses se passent dans cette requête.

  • Pour commencer, on divise par 8 parce que chaque page affiche au maximum 8 couleurs.
  • Ensuite, la première page d'instructions est la page 2 puisque la première page contient un récapitulatif du modèle, d'où le 2+ pour la première page de chaque plaque et le 1+ pour la dernière page de chaque plaque.
  • Pour finir, on utilise SUM en tant que fonction de fenêtrage pour compter le nombre de pages au total déjà utilisées précédemment. Utiliser simplement la division par 8 ne fonctionnerait pas puisque toutes les pages n'ont pas exactement 8 couleurs (l'unique page pour la plaque 1 n'utilise que 3 couleurs, par exemple). La CTE permet de ne pas répéter la définition de `page_count` trois fois.
model_id plate_y plate_x colors page_count page_start page_end
1 0 0 3 1 2 2
1 0 1 48 6 3 8
1 0 2 56 7 9 15
1 0 3 55 7 16 22
... ... ... ... ... ... ...

Nombre de modules requis par plaque

Cette vue est plus complexe qu'il n'y en a l'air : On veut savoir combien de modules acheter pour chaque plaque, en considérant qu'on a déjà acheté les modules de toutes les plaques précédentes, et donc qu'il y a des "restes" de pixels. Par exemple, si la plaque 1 avait 18 pixels de couleur verte, et que la plaque 2 en a besoin 24, comme un module contient 140 pixels, il n'est pas nécessaire d'acheter un module de couleur verte pour la plaque 2. Si la troisième plaque a besoin de 230 pixels verts, un seul module de 140 pixels verts est suffisant vu qu'il restait encore 140 - 18 - 24 = 98 pixels verts.

CREATE MATERIALIZED VIEW mv_modules_needed (model_id, color, plate_y, plate_x, modules_to_buy) AS
WITH T AS (
    SELECT
        model_id,
        color,
        plate_y,
        plate_x,
        SUM(amount) OVER curr AS pixels_needed_total,
        ceil(coalesce(SUM(amount) OVER curr, 0) / 140.0) - ceil(coalesce(SUM(amount) OVER prev, 0) / 140.0) AS modules_to_buy
    FROM mv_plate_color
    WINDOW prev AS (PARTITION BY model_id, color ORDER BY plate_y, plate_x ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING),
        curr AS (PARTITION BY model_id, color ORDER BY plate_y, plate_x ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
)
SELECT model_id, color, plate_y, plate_x, modules_to_buy
FROM T
WHERE modules_to_buy > 0;

Ça se corse, mais c'est la dernière requête compliquée.

La dernière valeur calculée dans la CTE est la différence entre

  • le nombre de modules requis pour terminer tous les pixels de cette couleur de la première plaque jusque la précédente, et
  • le nombre de modules requis pour terminer tous les pixels de cette couleur de la première plaque jusque celle-ci incluse.

Le WHERE permet de ne pas afficher les couleurs présentes mais qui ne requièrent l'achat d'aucun module car il reste assez de pixels de cette couleur.

Coût par plaque

Afin de découper notre commande en paquets de 50 euros (en admettant que la limite pour s'affranchir des frais de port n'ait pas changé), il est utile de connaître le coût par plaque, c'est-à-dire le coût d'une plaque vierge, plus le coût de tous les modules requis calculés précédemment, plus le coût de trois connecteurs (du coup on surestime légèrement le coût vu que les plaques du haut on besoin de deux connecteurs de moins et celles de gauche d'un connecteur de moins).

Les valeurs utilisées sont celles de notre dernière commande mais évoluent régulièrement (j'ai décidé de négliger le calcul d'optimisation de l'épargne des frais de port par rapport à l'augmentation du coût de la vie et en fonction de notre propre réserve d'argent et de l'utilisation qu'on veut en faire pour acheter des actifs financiers, si vous faites le calcul je vous laisse écrire un autre article sur Sfeir.dev ! 😆)

CREATE MATERIALIZED VIEW mv_cost_per_plate (model_id, plate_y, plate_x, plate_cost) AS
SELECT model_id, plate_y, plate_x,
       SUM(modules_to_buy) * 0.34 -- Price per module
           + 3 -- Price per plate
           + 0.39 -- Price for 3 connectors, overestimated for plates on the right/bottom edges
FROM mv_modules_needed
GROUP BY model_id, plate_y, plate_x
ORDER BY model_id, plate_y, plate_x
model_id plate_y plate_x plate_cost
1 0 0 8.83
1 0 1 22.43
1 0 2 12.23
1 0 3 13.59
... ... ... ...

Les coûts ont tendance à être plus importants pour les premières plaques puisque les modules servent régulièrement pour plusieurs plaques.

Conclusion

Nous voici parvenus au terme de cet article, résolument pratique comme promis. J'espère que vous avez pris plaisir à le lire et que vous y avez appris l'une ou l'autre chose par la même occasion ! Pour ma part, créer cette base de données, en plus de l'aspect utile, m'a beaucoup amusé.

Ah, et une dernière chose, voilà où on en est de notre projet.

Je vous laisse savourer tous les détails de cette photo !

Dernier