""" ============================================================================= MATRICE LED WS2812B 8 x 32 (256 pixels) — le « moteur » pour Raspberry Pi Workshop « Vitrine pixels » ============================================================================= Ce fichier est la SALLE DES MACHINES. Vous n'avez normalement pas besoin de le modifier, sauf les RÉGLAGES de la partie 1. Les autres programmes du dossier s'en servent : - lecteur.py joue les animations dessinées dans Krita (GIF, PNG…) ; - test_matrice.py vérifie le branchement et l'orientation ; - mes_animations.py animations écrites en code (comme sur l'Arduino). CE QUI CHANGE PAR RAPPORT À L'ARDUINO ------------------------------------- Le principe électrique est EXACTEMENT le même que dans le dossier matrice_8x32 : les 256 LED forment une chaîne, et on leur envoie une longue liste de couleurs par UN SEUL FIL de données. Ce qui change, c'est le « cerveau » : - l'Arduino est un petit microcontrôleur : il ne fait qu'une chose, le programme qu'on lui a téléversé ; - le Raspberry Pi est un vrai petit ORDINATEUR (avec Linux, des fichiers, le réseau…). Il peut donc LIRE DES IMAGES : on n'a plus besoin de traduire chaque dessin en code. Le langage aussi change : Python au lieu du C++ de l'Arduino. Python se lit presque comme de l'anglais, et il n'y a pas besoin de « téléverser » : on lance le programme directement. DEUX ÉCRANS POSSIBLES --------------------- Ce moteur sait afficher l'image : 1. sur la VRAIE matrice, si on est sur un Raspberry Pi avec la bibliothèque rpi_ws281x installée ; 2. dans un SIMULATEUR : une fenêtre sur votre ordinateur, avec 256 ronds de couleur. Pratique pour préparer ses animations chez soi, sans matériel. Le choix se fait tout seul (ou avec l'option --simulateur). Il existe aussi un 3e « écran », l'ENREGISTREUR : avec l'option --enregistrer fichier.gif, les images sont enregistrées dans un GIF animé. ORGANISATION DU FICHIER ----------------------- 1. Réglages : taille, broche, luminosité, limite de courant, orientation. 2. Les couleurs (avec leur nom en français). 3. La fonction XY() : de la position (x, y) au numéro de LED. 4. La correction gamma et la limite de courant. 5. Les écrans : la vraie matrice, le simulateur et l'enregistreur. 6. Les outils de dessin : pixel(), effacer(), montrer(), attendre()… """ import colorsys # conversions de couleurs (teinte → rouge, vert, bleu) import sys # pour lire les options de la ligne de commande import time # pour mesurer et attendre le temps qui passe # Pillow est la bibliothèque Python qui sait lire et manipuler les images. # Si elle manque : sudo apt install python3-pil (sur le Pi) # pip install pillow (sur un ordinateur) from PIL import Image # ============================================================================= # 1. RÉGLAGES # ============================================================================= # Le PANNEAU, tel qu'il est fabriqué : 32 colonnes × 8 lignes de LED, # câblées en serpentin par colonnes de 8. Ça ne change jamais. COLONNES_PANNEAU = 32 LIGNES_PANNEAU = 8 NB_LEDS = COLONNES_PANNEAU * LIGNES_PANNEAU # = 256 # ROTATION : comment le panneau est POSÉ dans la vitrine, en degrés. # 0 = couché (à l'horizontale) : l'image fait 32 de large × 8 de haut ; # 90 = debout (à la verticale) : l'image fait 8 de large × 32 de haut. # (180 et 270 existent aussi : couché à l'envers, debout dans l'autre sens. # Pour remettre l'image à l'endroit, INVERSER_X / INVERSER_Y suffisent.) ROTATION = 90 # Taille de l'IMAGE à dessiner (dans Krita, dans le code…), selon la rotation. # Tout le reste du programme utilise LARGEUR et HAUTEUR. if ROTATION in (90, 270): LARGEUR, HAUTEUR = LIGNES_PANNEAU, COLONNES_PANNEAU # 8 × 32 : debout else: LARGEUR, HAUTEUR = COLONNES_PANNEAU, LIGNES_PANNEAU # 32 × 8 : couché # Broche du Raspberry Pi reliée (à travers l'adaptateur 74AHCT125) au fil # VERT (DIN) de la matrice. Ce numéro est le numéro « GPIO », pas le numéro # de la patte : GPIO 18 = patte physique n° 12 du connecteur du Pi. # Ne changez pas sans raison : seules quelques broches savent produire # ce signal très rapide (GPIO 18 est la plus simple). BROCHE_GPIO = 18 # SIGNAL INVERSÉ : à mettre à True UNIQUEMENT si le signal passe par un # transistor (montage « sans 74AHCT125 », solution B du README). Un transistor # inverse le signal (un 1 devient un 0) : la bibliothèque l'inverse alors # une seconde fois avant de l'envoyer, et tout redevient juste. INVERSER_SIGNAL = False # LUMINOSITÉ générale, de 0 (éteint) à 255 (maximum). # Ces LED sont TRÈS lumineuses : 40 suffit en intérieur. # En vitrine, en plein jour, on pourra monter vers 100 ou plus, # à condition d'avoir une alimentation assez puissante. LUMINOSITE = 40 # SÉCURITÉ : courant maximum autorisé, en milliampères (1000 mA = 1 A). # Une LED en blanc à fond consomme environ 60 mA (20 mA par couleur). # 256 LED × 60 mA = plus de 15 A ! L'alimentation en fournit beaucoup moins. # Avant chaque image, le moteur CALCULE ce que les LED vont consommer et, # si ça dépasse cette limite, il baisse la luminosité de cette image. # → Réglez cette valeur UN PEU EN DESSOUS de la limite de l'alimentation # (ex. : alimentation d'établi limitée à 3 A → 2500 mA ici). COURANT_MAX_MA = 2500 # SENS de l'image (voir test_matrice.py), une fois ROTATION choisie. # Le pixel ROUGE du test doit être EN HAUT À GAUCHE, le VERT EN HAUT À DROITE. # - rouge et vert inversés (gauche/droite) → INVERSER_X = True # - rouge et vert en bas au lieu d'en haut → INVERSER_Y = True # - les deux à la fois (image à l'envers) → les deux à True INVERSER_X = False INVERSER_Y = False # CORRECTION GAMMA (voir partie 4). 2.2 = comme un écran d'ordinateur. # 1.0 = pas de correction (les couleurs sombres paraîtront trop claires). GAMMA = 2.2 # ============================================================================= # 2. LES COULEURS # ============================================================================= # # En Python, une couleur est un « tuple » de trois nombres entre # parenthèses : (rouge, vert, bleu), chacun de 0 à 255. # (255, 0, 0) = rouge vif # (255, 128, 0) = orange # (20, 0, 40) = violet sombre # # On donne un nom aux plus courantes. Les noms en MAJUSCULES sont, par # convention, des valeurs qui ne changent pas (des « constantes »). NOIR = (0, 0, 0) BLANC = (255, 255, 255) ROUGE = (255, 0, 0) ORANGE = (255, 100, 0) JAUNE = (255, 200, 0) VERT = (0, 255, 0) CYAN = (0, 255, 255) BLEU = (0, 0, 255) VIOLET = (130, 0, 255) MAGENTA = (255, 0, 255) ROSE = (255, 20, 100) def teinte(t, saturation=255, valeur=255): """Fabrique une couleur à partir d'une TEINTE, comme CHSV dans FastLED. t (la teinte) va de 0 à 255 et fait le tour de l'arc-en-ciel : 0 rouge, 32 orange, 64 jaune, 96 vert, 128 cyan, 160 bleu, 192 violet, 224 rose… puis retour au rouge (256 = 0). saturation : 0 = gris/blanc, 255 = couleur pure. valeur : 0 = noir, 255 = pleine lumière. """ # colorsys travaille avec des nombres entre 0 et 1 : on divise par 255, # puis on remultiplie le résultat par 255. # « % 256 » = reste de la division par 256 : 300 devient 44, la teinte boucle. r, v, b = colorsys.hsv_to_rgb((t % 256) / 256, saturation / 255, valeur / 255) return (round(r * 255), round(v * 255), round(b * 255)) # ============================================================================= # 3. DE LA POSITION (x, y) AU NUMÉRO DE LED # ============================================================================= # # Pour dessiner, on pense en coordonnées, sur l'IMAGE telle qu'on la voit : # x = colonne, de 0 (à gauche) à LARGEUR - 1 (à droite) # y = ligne, de 0 (en haut) à HAUTEUR - 1 (en bas) # Le point (0, 0) est en haut à gauche, comme dans Krita. # # Pour trouver la LED correspondante, trois étapes : # # 1. Retourner l'image si besoin (INVERSER_X / INVERSER_Y). # # 2. Tourner : passer de la position sur l'IMAGE à la position sur le # PANNEAU (px de 0 à 31, py de 0 à 7, panneau vu couché). Exemple, # panneau debout (ROTATION = 90, tourné d'un quart de tour vers la # droite) : le haut de l'image correspond à la gauche du panneau, et la # droite de l'image au haut du panneau. # # 3. Le serpentin : les LED sont numérotées dans l'ordre de la chaîne, qui # descend la 1re colonne du panneau, remonte la 2e… # # px=0 px=1 px=2 px=3 # py=0 0 15 16 31 … # py=1 1 14 17 30 # … … … … … # py=7 7 → 8 23 → 24 # # L'étape 3 est la même fonction que dans matrice_8x32.ino. def XY(x, y): """Renvoie le numéro (0 à 255) de la LED qui affiche le pixel (x, y) de l'image.""" # 1. Retourner. if INVERSER_X: x = (LARGEUR - 1) - x if INVERSER_Y: y = (HAUTEUR - 1) - y # 2. Tourner : position (px, py) sur le panneau couché. if ROTATION == 90: px, py = y, (LIGNES_PANNEAU - 1) - x elif ROTATION == 180: px, py = (COLONNES_PANNEAU - 1) - x, (LIGNES_PANNEAU - 1) - y elif ROTATION == 270: px, py = (COLONNES_PANNEAU - 1) - y, x else: px, py = x, y # 3. Le serpentin. if px % 2 == 0: # Colonne paire du panneau : la chaîne DESCEND. return px * LIGNES_PANNEAU + py else: # Colonne impaire : la chaîne REMONTE. return px * LIGNES_PANNEAU + (LIGNES_PANNEAU - 1 - py) # ============================================================================= # 4. CORRECTION GAMMA ET LIMITE DE COURANT # ============================================================================= # # POURQUOI UNE « CORRECTION GAMMA » ? # Notre œil n'est pas une règle graduée : il est beaucoup plus sensible aux # différences dans les sombres que dans les clairs. Les écrans d'ordinateur # en tiennent compte : dans Krita, un gris à 128 (la moitié de 255) ne # produit en réalité qu'environ 22 % de la lumière du blanc, et c'est ce # que notre œil perçoit comme « gris moyen ». # # Les LED, elles, sont « honnêtes » : 128 donne VRAIMENT 50 % de lumière, # ce que l'œil voit comme presque blanc. Sans correction, les dégradés # dessinés dans Krita paraîtraient délavés sur la matrice. # # On prépare donc une TABLE de 256 cases : pour chaque valeur de 0 à 255 # venue de l'image, la valeur à envoyer à la LED pour que ça « ressemble » # à l'écran. Calculée une seule fois, au démarrage. # # Conséquence à connaître : les couleurs très sombres de l'image deviennent # 0 (éteint) sur la matrice. Voir le GUIDE_KRITA.md. TABLE_GAMMA = [round(255 * (i / 255) ** GAMMA) for i in range(256)] # (Cette ligne se lit : « pour chaque i de 0 à 255, calculer 255 × (i/255)^gamma ». # Les crochets fabriquent une liste : c'est une « liste en compréhension ».) # Consommation approximative d'une LED, pour la limite de courant. MA_PAR_COULEUR = 20 # une couleur (R, G ou B) à fond ≈ 20 mA MA_AU_REPOS = 1 # la petite puce de chaque LED consomme ≈ 1 mA même éteinte def pixels_de(image): """Liste des couleurs (r, v, b) de tous les pixels d'une image RVB, ligne par ligne : (0,0), (1,0), (2,0)… puis la ligne suivante. (tobytes() donne les octets r, v, b, r, v, b… : on les regroupe par 3.)""" octets = image.tobytes() return [tuple(octets[i:i + 3]) for i in range(0, len(octets), 3)] def preparer_valeurs(image): """Transforme une image Pillow en liste de 256 couleurs prêtes à envoyer. Étapes : correction gamma → luminosité → limite de courant. Renvoie une liste de tuples (r, v, b), dans l'ordre des lignes de l'image (pixel (0,0), (1,0), (2,0)… puis la ligne suivante). """ valeurs = [] for (r, v, b) in pixels_de(image): # tous les pixels, un par un valeurs.append(( TABLE_GAMMA[r] * LUMINOSITE // 255, # « // » = division entière TABLE_GAMMA[v] * LUMINOSITE // 255, TABLE_GAMMA[b] * LUMINOSITE // 255, )) # Estimation du courant : on additionne toutes les composantes. total = sum(r + v + b for (r, v, b) in valeurs) repos = NB_LEDS * MA_AU_REPOS courant = repos + total * MA_PAR_COULEUR / 255 if courant > COURANT_MAX_MA: # Trop gourmand : on baisse toute l'image dans la bonne proportion. facteur = (COURANT_MAX_MA - repos) / (courant - repos) valeurs = [(int(r * facteur), int(v * facteur), int(b * facteur)) for (r, v, b) in valeurs] return valeurs def courant_estime_ma(image): """Courant (en mA) que consommerait cette image, limite comprise.""" valeurs = preparer_valeurs(image) total = sum(r + v + b for (r, v, b) in valeurs) return NB_LEDS * MA_AU_REPOS + total * MA_PAR_COULEUR / 255 # ============================================================================= # 5. LES ÉCRANS # ============================================================================= # # Une « classe » est un modèle d'objet : elle regroupe des données et les # fonctions (on dit « méthodes ») qui s'en servent. Nos deux écrans ont les # mêmes méthodes : afficher(image), attendre(secondes) et eteindre(). # Le reste du programme peut donc utiliser l'un ou l'autre sans changer # une ligne : c'est tout l'intérêt. class MatriceReelle: """La vraie matrice, branchée sur le Raspberry Pi.""" def __init__(self): # On importe la bibliothèque ICI, et pas en haut du fichier : # comme ça, le simulateur fonctionne sur un ordinateur où elle # n'est pas installée. from rpi_ws281x import PixelStrip, Color, ws self.Color = Color self.bande = PixelStrip( NB_LEDS, # nombre de LED BROCHE_GPIO, # broche des données (GPIO 18) freq_hz=800_000, # 800 000 bits par seconde, comme FastLED dma=10, # canal interne utilisé pour envoyer les bits invert=INVERSER_SIGNAL, # True seulement avec le montage à transistor brightness=255, # on gère la luminosité nous-mêmes (partie 4) channel=0, strip_type=ws.WS2811_STRIP_GRB, # ordre des couleurs : Vert, Rouge, Bleu ) self.bande.begin() # démarre l'envoi (il faut être administrateur : sudo) def afficher(self, image): valeurs = preparer_valeurs(image) for y in range(HAUTEUR): for x in range(LARGEUR): r, v, b = valeurs[y * LARGEUR + x] self.bande.setPixelColor(XY(x, y), self.Color(r, v, b)) self.bande.show() # envoie les 256 couleurs dans le fil (≈ 8 ms) def attendre(self, secondes): if secondes > 0: time.sleep(secondes) def eteindre(self): for i in range(NB_LEDS): self.bande.setPixelColor(i, 0) self.bande.show() class Simulateur: """Une fenêtre sur l'ordinateur qui imite la matrice (256 ronds).""" # Taille d'un pixel à l'écran, en points : plus petit si l'image est # haute (panneau debout), pour que la fenêtre tienne dans l'écran. TAILLE = min(26, 640 // max(LARGEUR, HAUTEUR)) MARGE = 4 # espace entre deux pixels def __init__(self, titre="Simulateur — matrice 8 × 32"): # tkinter est la bibliothèque de fenêtres livrée avec Python. import tkinter self.fenetre = tkinter.Tk() self.fenetre.title(titre) self.fenetre.configure(background="black") pas = self.TAILLE + self.MARGE self.toile = tkinter.Canvas(self.fenetre, width=LARGEUR * pas + self.MARGE, height=HAUTEUR * pas + self.MARGE, background="#111", highlightthickness=0) self.toile.pack(padx=10, pady=10) # Un rond par pixel. On garde leurs numéros pour changer leur couleur. self.ronds = {} for y in range(HAUTEUR): for x in range(LARGEUR): x0 = self.MARGE + x * pas y0 = self.MARGE + y * pas self.ronds[(x, y)] = self.toile.create_oval( x0, y0, x0 + self.TAILLE, y0 + self.TAILLE, fill="#000", outline="#222") # Quand on ferme la fenêtre, on arrête proprement le programme. self.ouverte = True self.fenetre.protocol("WM_DELETE_WINDOW", self._fermer) self.fenetre.update() def _fermer(self): self.ouverte = False def _rafraichir(self): if not self.ouverte: self.fenetre.destroy() raise SystemExit("Fenêtre du simulateur fermée.") self.fenetre.update() def afficher(self, image): # Le simulateur montre les couleurs de l'image telles quelles : # l'écran de l'ordinateur fait déjà sa propre « correction gamma ». for y in range(HAUTEUR): for x in range(LARGEUR): r, v, b = image.getpixel((x, y)) self.toile.itemconfig(self.ronds[(x, y)], fill=f"#{r:02x}{v:02x}{b:02x}") self._rafraichir() def attendre(self, secondes): # On ne peut pas simplement dormir : la fenêtre se figerait. # On attend par petits bouts en la laissant se redessiner. fin = time.monotonic() + secondes while True: self._rafraichir() reste = fin - time.monotonic() if reste <= 0: break time.sleep(min(reste, 0.01)) def eteindre(self): if self.ouverte: self.afficher(Image.new("RGB", (LARGEUR, HAUTEUR))) class Enregistreur: """Un « écran » qui n'affiche rien : il ENREGISTRE chaque image montrée dans un GIF animé. Une animation écrite en code devient ainsi un fichier, que le lecteur jouera comme un dessin de Krita. Option : --enregistrer animations/10_mon_animation.gif""" def __init__(self, fichier): self.fichier = fichier self.images = [] self.durees = [] # en secondes, une par image def afficher(self, image): self.images.append(image.copy()) # copy() : une photo de la toile à cet instant self.durees.append(0) def attendre(self, secondes): # Le temps d'attente devient la durée de l'image affichée juste avant. if self.durees and secondes > 0: self.durees[-1] += secondes def eteindre(self): if not self.images: return # Le format GIF ne sait pas afficher une image moins de 20 ms. durees_ms = [max(20, round(d * 1000)) for d in self.durees] self.images[0].save(self.fichier, save_all=True, append_images=self.images[1:], duration=durees_ms, loop=0) print(f"Enregistré : {self.fichier} ({len(self.images)} images, " f"{sum(durees_ms) / 1000:.1f} s)") self.images = [] def ouvrir_ecran(simulateur=None): """Choisit l'écran : vraie matrice si possible, sinon simulateur. simulateur = True → toujours le simulateur ; simulateur = None → décide tout seul (option --simulateur sur la ligne de commande, ou bibliothèque rpi_ws281x absente). Avec l'option « --enregistrer fichier.gif », on enregistre au lieu d'afficher. """ if "--enregistrer" in sys.argv: position = sys.argv.index("--enregistrer") if position + 1 >= len(sys.argv): raise SystemExit("Indiquez un nom de fichier après --enregistrer") print("Écran : enregistrement dans", sys.argv[position + 1]) return Enregistreur(sys.argv[position + 1]) if simulateur is None: simulateur = "--simulateur" in sys.argv if not simulateur: try: ecran = MatriceReelle() print("Écran : vraie matrice (GPIO %d)." % BROCHE_GPIO) return ecran except ImportError: print("Bibliothèque rpi_ws281x absente : on ouvre le simulateur.") except RuntimeError as erreur: # Erreur typique : programme lancé sans « sudo ». print("Impossible de parler à la matrice :", erreur) print("Sur le Raspberry Pi, lancez le programme avec sudo.") raise SystemExit(1) print("Écran : simulateur.") return Simulateur() # ============================================================================= # 6. LES OUTILS DE DESSIN # ============================================================================= # # Les mêmes que sur l'Arduino : on dessine sur une TOILE invisible # (une image Pillow de 32 × 8 pixels), puis on la montre d'un coup. toile = Image.new("RGB", (LARGEUR, HAUTEUR)) # l'image en préparation _ecran = None # l'écran, ouvert par demarrer() def demarrer(simulateur=None): """À appeler une fois au début du programme : ouvre l'écran.""" global _ecran # « global » : on modifie la variable du fichier, pas une copie _ecran = ouvrir_ecran(simulateur) effacer() montrer() def pixel(x, y, couleur): """Colore le pixel (x, y). En dehors de la matrice : on ne fait rien.""" if 0 <= x < LARGEUR and 0 <= y < HAUTEUR: toile.putpixel((int(x), int(y)), tuple(couleur)) def lire_pixel(x, y): """Renvoie la couleur actuelle du pixel (x, y) sur la toile.""" if 0 <= x < LARGEUR and 0 <= y < HAUTEUR: return toile.getpixel((int(x), int(y))) return NOIR def effacer(couleur=NOIR): """Remplit toute la toile d'une couleur (noir par défaut = tout éteindre).""" toile.paste(tuple(couleur), (0, 0, LARGEUR, HAUTEUR)) def assombrir(pourcentage): """Assombrit toute la toile, comme fadeToBlackBy() de FastLED. assombrir(25) = chaque pixel perd 25 % de sa lumière.""" garde = 1 - pourcentage / 100 toile.putdata([(int(r * garde), int(v * garde), int(b * garde)) for (r, v, b) in pixels_de(toile)]) def montrer(image=None): """Affiche la toile (ou une autre image de 32 × 8) sur l'écran.""" _ecran.afficher(image if image is not None else toile) def attendre(millisecondes): """Attend, en millisecondes, comme delay() sur l'Arduino.""" _ecran.attendre(millisecondes / 1000) def eteindre(): """Éteint toute la matrice. À appeler à la fin du programme.""" if _ecran is not None: _ecran.eteindre()