""" ============================================================================= LECTEUR D'ANIMATIONS — de Krita à la matrice Workshop « Vitrine pixels » ============================================================================= Ce programme joue EN BOUCLE toutes les animations d'un dossier : - des GIF animés (coeur.gif) - des PNG animés (APNG) (vague.png) - des WebP animés (pluie.webp) - des images fixes (logo.png : affichée quelques secondes) - des SÉQUENCES d'images (un sous-dossier rempli de PNG numérotés : vague_0000.png, vague_0001.png…, comme Krita les exporte) Elles sont jouées par ordre alphabétique : pour choisir l'ordre, commencez les noms par un numéro (01_coeur.gif, 02_vague/…). Le dossier est relu à chaque tour : on peut ajouter ou retirer des animations sans arrêter le programme. COMMENT LE LANCER ----------------- Sur le Raspberry Pi (sudo est obligatoire pour parler à la matrice) : sudo ~/vitrine-env/bin/python lecteur.py Sur votre ordinateur, avec le simulateur : python3 lecteur.py --simulateur Pour essayer UN SEUL fichier ou dossier : python3 lecteur.py --simulateur mon_animation.gif Pour lire un autre dossier : python3 lecteur.py --dossier /chemin/vers/mes/animations ORGANISATION DU PROGRAMME ------------------------- 1. Réglages. 2. Trouver les animations dans le dossier. 3. Charger une animation : la découper en images de 32 × 8. 4. Jouer une animation. 5. Les horaires (allumer la vitrine seulement à certaines heures). 6. Le programme principal. """ import argparse # pour lire les options (--simulateur, --dossier…) import datetime # pour connaître l'heure (horaires de la vitrine) import os # pour parcourir les dossiers et les fichiers import time from PIL import Image, ImageSequence import matrice # notre moteur (fichier matrice.py, dans le même dossier) from matrice import LARGEUR, HAUTEUR # ============================================================================= # 1. RÉGLAGES # ============================================================================= # Où chercher les animations ? On essaie ces dossiers dans l'ordre et on # prend le premier qui existe. # - /boot/firmware/animations : sur la carte SD du Pi, dans la partie # qu'on peut ouvrir depuis N'IMPORTE QUEL ordinateur (Windows, Mac, # Linux). Voir MODE_VITRINE.md. # - /boot/animations : la même chose sur les systèmes plus anciens # (Raspbian 11 « Bullseye » et avant). # - le dossier « animations » à côté de ce programme. ICI = os.path.dirname(os.path.abspath(__file__)) DOSSIERS_POSSIBLES = [ "/boot/firmware/animations", "/boot/animations", os.path.join(ICI, "animations"), ] # Vitesse des SÉQUENCES d'images (sous-dossiers de PNG), en images par # seconde, si le dossier ne contient pas de fichier « images_par_seconde.txt ». # Les GIF, APNG et WebP, eux, contiennent leur propre vitesse : Krita # l'enregistre à l'export. IMAGES_PAR_SECONDE = 12 # Durée minimum d'une animation, en secondes. Une animation courte # (un cœur qui bat en 1 s) est rejouée jusqu'à atteindre cette durée. # Une image fixe reste affichée pendant cette durée. DUREE_MINIMUM = 10 # Noir entre deux animations, en secondes (0 = enchaîner directement). PAUSE_ENTRE_ANIMATIONS = 0.3 # HORAIRES de la vitrine : la matrice ne s'allume qu'entre ces deux heures. # None = toujours allumée. Exemple : HORAIRES = ("09:00", "22:30") HORAIRES = None # Extensions de fichiers reconnues (en minuscules). EXTENSIONS = (".gif", ".png", ".apng", ".webp", ".jpg", ".jpeg", ".bmp") # ============================================================================= # 2. TROUVER LES ANIMATIONS # ============================================================================= def trouver_dossier(): """Renvoie le premier dossier d'animations qui existe.""" for dossier in DOSSIERS_POSSIBLES: if os.path.isdir(dossier): return dossier return DOSSIERS_POSSIBLES[-1] def est_cache(nom): """Les fichiers qui commencent par un point sont « cachés ». Un Mac en crée sur les clés USB et cartes SD (._coeur.gif, .DS_Store) : ce ne sont pas des images, il faut les ignorer.""" return nom.startswith(".") def images_du_dossier(dossier): """Liste triée des images contenues dans un dossier (pour une séquence).""" return sorted( os.path.join(dossier, nom) for nom in os.listdir(dossier) if not est_cache(nom) and nom.lower().endswith(EXTENSIONS) ) def lister_animations(dossier): """Liste, par ordre alphabétique, ce qu'il y a à jouer dans le dossier : les fichiers images, et les sous-dossiers qui contiennent des images.""" if not os.path.isdir(dossier): return [] resultat = [] for nom in sorted(os.listdir(dossier), key=str.lower): if est_cache(nom): continue chemin = os.path.join(dossier, nom) if os.path.isdir(chemin): if images_du_dossier(chemin): resultat.append(chemin) elif nom.lower().endswith(EXTENSIONS): resultat.append(chemin) return resultat # ============================================================================= # 3. CHARGER UNE ANIMATION # ============================================================================= # # Une animation, pour nous, c'est une LISTE d'étapes. Chaque étape est un # couple (image de 32 × 8, durée en secondes). def adapter_image(image, nom): """Met n'importe quelle image au format de la matrice : 32 × 8, RVB.""" # 1) La transparence devient du noir (= LED éteinte). image = image.convert("RGBA") fond = Image.new("RGBA", image.size, (0, 0, 0, 255)) image = Image.alpha_composite(fond, image).convert("RGB") largeur, hauteur = image.size if (largeur, hauteur) == (LARGEUR, HAUTEUR): return image # parfait, rien à faire # 2) L'image est un AGRANDISSEMENT exact (320 × 80 = 32 × 8 fois 10) : # on la réduit avec « le plus proche voisin », qui garde des pixels # nets sans inventer de couleurs intermédiaires. if largeur % LARGEUR == 0 and hauteur % HAUTEUR == 0 \ and largeur // LARGEUR == hauteur // HAUTEUR: return image.resize((LARGEUR, HAUTEUR), PLUS_PROCHE_VOISIN) # 3) Autre taille (par exemple 39 × 7, le format final) : on la pose # au centre, sans la déformer. Ce qui dépasse est coupé. avertir(nom, f"taille {largeur} × {hauteur} au lieu de {LARGEUR} × {HAUTEUR} : " "image centrée (ce qui dépasse est coupé)") toile = Image.new("RGB", (LARGEUR, HAUTEUR)) toile.paste(image, ((LARGEUR - largeur) // 2, (HAUTEUR - hauteur) // 2)) return toile # Le nom du filtre « plus proche voisin » a changé avec Pillow 9.1 : # on prend le nouveau s'il existe, sinon l'ancien (Raspbian 11 a Pillow 8). PLUS_PROCHE_VOISIN = getattr(Image, "Resampling", Image).NEAREST _deja_averti = set() def avertir(nom, message): """Affiche un avertissement, une seule fois par fichier et par message.""" if (nom, message) not in _deja_averti: _deja_averti.add((nom, message)) print(f" ⚠ {os.path.basename(nom)} : {message}") def lire_vitesse(dossier): """Lit « images_par_seconde.txt » dans le dossier d'une séquence, s'il existe.""" fichier = os.path.join(dossier, "images_par_seconde.txt") try: with open(fichier) as f: return float(f.read().strip().replace(",", ".")) except (OSError, ValueError): return IMAGES_PAR_SECONDE def charger_fichier(chemin): """Découpe un fichier image (fixe ou animé) en liste d'étapes.""" etapes = [] with Image.open(chemin) as fichier: # ImageSequence.Iterator parcourt les images d'un fichier animé. # Pour une image fixe, il n'y en a qu'une. for image in ImageSequence.Iterator(fichier): # La durée de chaque image est rangée dans le fichier, en ms. # Certains GIF indiquent 0 : on prend alors 100 ms. duree_ms = image.info.get("duration") or 100 etapes.append((adapter_image(image, chemin), duree_ms / 1000)) if len(etapes) == 1: # Image fixe : on l'affiche pendant toute la durée minimum. etapes = [(etapes[0][0], DUREE_MINIMUM)] return etapes def charger_sequence(dossier): """Charge un dossier de PNG numérotés comme une animation.""" duree = 1 / lire_vitesse(dossier) etapes = [] for chemin in images_du_dossier(dossier): with Image.open(chemin) as image: etapes.append((adapter_image(image, dossier), duree)) return etapes def charger(chemin): """Charge une animation, que ce soit un fichier ou un dossier.""" if os.path.isdir(chemin): return charger_sequence(chemin) return charger_fichier(chemin) # ============================================================================= # 4. JOUER UNE ANIMATION # ============================================================================= def jouer(etapes, duree_minimum=DUREE_MINIMUM): """Joue la liste d'étapes, et la rejoue jusqu'à atteindre la durée minimum.""" debut = time.monotonic() while True: for image, duree in etapes: moment = time.monotonic() matrice.montrer(image) # On retire le temps déjà passé à afficher, pour garder le rythme. matrice.attendre((duree - (time.monotonic() - moment)) * 1000) if time.monotonic() - debut >= duree_minimum: break def ecran_d_attente(dossier): """Quand il n'y a rien à jouer : un petit point qui respire en bas à droite.""" print(f"Aucune animation dans {dossier} : en attente…") for i in range(50): matrice.effacer() lumiere = int(60 + 60 * abs(25 - i) / 25) matrice.pixel(LARGEUR - 1, HAUTEUR - 1, (0, lumiere, lumiere)) matrice.montrer() matrice.attendre(100) # ============================================================================= # 5. LES HORAIRES # ============================================================================= def en_horaires(): """Vrai si la vitrine doit être allumée à cette heure-ci.""" if HORAIRES is None: return True maintenant = datetime.datetime.now().strftime("%H:%M") # ex. "14:05" debut, fin = HORAIRES if debut <= fin: return debut <= maintenant < fin # ex. de 09:00 à 22:30 return maintenant >= debut or maintenant < fin # ex. de 18:00 à 02:00 (passe minuit) # ============================================================================= # 6. LE PROGRAMME PRINCIPAL # ============================================================================= def main(): options = argparse.ArgumentParser(description="Joue les animations sur la matrice 8 × 32.") options.add_argument("chemin", nargs="?", help="un seul fichier ou dossier à jouer (facultatif)") options.add_argument("--dossier", help="dossier d'animations à lire") options.add_argument("--simulateur", action="store_true", help="afficher dans une fenêtre au lieu de la matrice") args = options.parse_args() matrice.demarrer(simulateur=args.simulateur) dossier = args.dossier or trouver_dossier() try: while True: # boucle infinie : la vitrine tourne jusqu'à ce qu'on l'arrête if not en_horaires(): matrice.eteindre() matrice.attendre(30_000) # on revérifie l'heure toutes les 30 s continue # Un seul fichier demandé, ou tout le dossier ? playlist = [args.chemin] if args.chemin else lister_animations(dossier) if not playlist: ecran_d_attente(dossier) continue for chemin in playlist: print("▶", os.path.relpath(chemin, dossier) if not args.chemin else chemin) try: etapes = charger(chemin) except Exception as erreur: # Un fichier abîmé ne doit pas arrêter la vitrine : on le saute. print(f" ✗ impossible de lire ce fichier ({erreur}) : on passe au suivant") continue if etapes: jouer(etapes) if PAUSE_ENTRE_ANIMATIONS > 0: matrice.effacer() matrice.montrer() matrice.attendre(PAUSE_ENTRE_ANIMATIONS * 1000) if not en_horaires(): break except KeyboardInterrupt: # Ctrl + C dans le terminal : on sort proprement. print("\nArrêt demandé.") finally: # « finally » s'exécute toujours, même en cas d'erreur : # on ne laisse jamais la matrice allumée en partant. try: matrice.eteindre() except Exception: pass # Cette ligne veut dire : « si on lance CE fichier directement, exécuter # main() ». Quand un autre programme l'importe (outils/verifier_animation.py), # main() n'est pas lancé : il peut juste réutiliser ses fonctions. if __name__ == "__main__": main()