Files
vitrine-pixels/8x32 PI/lecteur.py
T
2026-10-08 15:25:14 +02:00

345 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
=============================================================================
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()