This commit is contained in:
bach
2026-10-08 15:25:14 +02:00
parent 32e617f67b
commit da4fac3af7
64 changed files with 3921 additions and 0 deletions
+344
View File
@@ -0,0 +1,344 @@
"""
=============================================================================
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()