345 lines
13 KiB
Python
345 lines
13 KiB
Python
"""
|
||
=============================================================================
|
||
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()
|