- Vitesse de lecture : réglage VITESSE du lecteur et menu « Vitesse » sur la page web (×0,25 à ×2, gardé dans .vitesse.txt du dossier des animations). - QR code d'accès à la page web et affiche A5 (outils/generer_qrcode.py → qrcode/) : une coulée inspirée de « cascade » en haut, une partie de « tetris » en bas, couleurs tirées des animations de l'équipe (qrcode/sources/), titre en pixels de couleur. - Matrice de la vitrine : image retournée d'un demi-tour (INVERSER_X et INVERSER_Y) et nouveau réglage DECALAGE_X / DECALAGE_Y (ici 1 pixel vers la droite). - Doc : README, INTERFACE_WEB.md et CLAUDE.md à jour. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
491 lines
19 KiB
Python
491 lines
19 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. Parler avec le serveur web (serveur_web.py).
|
||
6. Les horaires (allumer la vitrine seulement à certaines heures).
|
||
7. Le programme principal.
|
||
"""
|
||
|
||
import argparse # pour lire les options (--simulateur, --dossier…)
|
||
import sys
|
||
import datetime # pour connaître l'heure (horaires de la vitrine)
|
||
import os # pour parcourir les dossiers et les fichiers
|
||
import signal # pour réagir quand Linux demande au programme de s'arrêter
|
||
import tempfile # pour trouver le dossier temporaire de l'ordinateur
|
||
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
|
||
|
||
# VITESSE de lecture de toutes les animations :
|
||
# 1 = la vitesse prévue dans Krita ;
|
||
# 0.5 = deux fois plus lent ; 2 = deux fois plus vite.
|
||
# La page web peut la changer : elle l'écrit alors dans le fichier caché
|
||
# « .vitesse.txt » du dossier des animations, qui a la priorité sur ce réglage.
|
||
VITESSE = 1.0
|
||
|
||
# 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) L'image est dessinée dans l'AUTRE SENS (couchée, 32 × 8, alors que la
|
||
# matrice est debout, ou l'inverse) : on la fait tourner d'un quart de
|
||
# tour, comme un texte sur le dos d'un livre (il se lit de bas en haut).
|
||
if LARGEUR != HAUTEUR and largeur % HAUTEUR == 0 and hauteur % LARGEUR == 0 \
|
||
and largeur // HAUTEUR == hauteur // LARGEUR:
|
||
avertir(nom, f"image couchée ({largeur} × {hauteur}) sur une matrice "
|
||
f"{'debout' if HAUTEUR > LARGEUR else 'couchée'} : tournée d'un quart de tour")
|
||
image = image.transpose(UN_QUART_DE_TOUR)
|
||
return image.resize((LARGEUR, HAUTEUR), PLUS_PROCHE_VOISIN)
|
||
|
||
# 4) 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
|
||
# Idem pour « tourner d'un quart de tour dans le sens inverse des aiguilles d'une montre ».
|
||
UN_QUART_DE_TOUR = getattr(Image, "Transpose", Image).ROTATE_90
|
||
|
||
_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 lire_vitesse_de_lecture(dossier):
|
||
"""La vitesse choisie sur la page web (fichier .vitesse.txt), sinon VITESSE."""
|
||
try:
|
||
with open(os.path.join(dossier, ".vitesse.txt")) as f:
|
||
vitesse = float(f.read().strip().replace(",", "."))
|
||
return min(4.0, max(0.1, vitesse)) # entre ×0,1 et ×4, par prudence
|
||
except (OSError, ValueError):
|
||
return VITESSE
|
||
|
||
|
||
def jouer(etapes, duree_minimum=DUREE_MINIMUM, vitesse=1.0):
|
||
"""Joue la liste d'étapes, et la rejoue jusqu'à atteindre la durée minimum.
|
||
vitesse = 0.5 : chaque image reste affichée deux fois plus longtemps.
|
||
S'arrête plus tôt si le serveur web demande autre chose (partie 5)."""
|
||
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.
|
||
if not attendre_ou_interrompre(duree / vitesse - (time.monotonic() - moment)):
|
||
return
|
||
if time.monotonic() - debut >= duree_minimum:
|
||
break
|
||
|
||
|
||
def attendre_ou_interrompre(secondes):
|
||
"""Attend, par petits bouts de 0,2 s, en surveillant les demandes du
|
||
serveur web. Renvoie False si une demande est arrivée entre-temps."""
|
||
fin = time.monotonic() + secondes
|
||
while True:
|
||
if demande_en_attente():
|
||
return False
|
||
reste = fin - time.monotonic()
|
||
if reste <= 0:
|
||
return True
|
||
matrice.attendre(min(reste, 0.2) * 1000)
|
||
|
||
|
||
def jouer_animation(chemin, dossier):
|
||
"""Charge et joue UNE animation (fichier ou séquence), puis un court noir."""
|
||
nom = os.path.relpath(chemin, dossier)
|
||
print("▶", nom)
|
||
ecrire_en_cours(nom)
|
||
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")
|
||
return
|
||
if etapes:
|
||
jouer(etapes, vitesse=lire_vitesse_de_lecture(dossier))
|
||
if PAUSE_ENTRE_ANIMATIONS > 0:
|
||
matrice.effacer()
|
||
matrice.montrer()
|
||
matrice.attendre(PAUSE_ENTRE_ANIMATIONS * 1000)
|
||
|
||
|
||
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()
|
||
if not attendre_ou_interrompre(0.1):
|
||
return # une demande est arrivée depuis la page web
|
||
|
||
|
||
# =============================================================================
|
||
# 5. PARLER AVEC LE SERVEUR WEB
|
||
# =============================================================================
|
||
#
|
||
# Le serveur web (serveur_web.py) et le lecteur sont deux programmes séparés,
|
||
# qui tournent en même temps. Pour se parler, ils s'échangent de tout petits
|
||
# fichiers, comme des post-it, dans un dossier qui est en MÉMOIRE VIVE
|
||
# (/run/vitrine-pixels) : rien n'est écrit sur la carte SD.
|
||
# - « en_cours » : le lecteur y écrit le nom de l'animation qu'il joue,
|
||
# pour que la page web puisse l'afficher ;
|
||
# - « demande » : le serveur y écrit le nom d'une animation à jouer
|
||
# TOUT DE SUITE, ou « suivante » pour passer à la suivante.
|
||
# Le lecteur la lit, puis efface le post-it.
|
||
|
||
SUIVANTE = "suivante"
|
||
|
||
|
||
def trouver_dossier_etat():
|
||
"""/run/vitrine-pixels sur le Pi ; le dossier temporaire sur un ordinateur
|
||
où l'on n'a pas le droit d'écrire dans /run."""
|
||
for dossier in ("/run/vitrine-pixels",
|
||
os.path.join(tempfile.gettempdir(), "vitrine-pixels")):
|
||
try:
|
||
os.makedirs(dossier, exist_ok=True)
|
||
if os.access(dossier, os.W_OK):
|
||
return dossier
|
||
except OSError:
|
||
pass
|
||
return tempfile.gettempdir()
|
||
|
||
|
||
DOSSIER_ETAT = trouver_dossier_etat()
|
||
FICHIER_EN_COURS = os.path.join(DOSSIER_ETAT, "en_cours")
|
||
FICHIER_DEMANDE = os.path.join(DOSSIER_ETAT, "demande")
|
||
|
||
|
||
def ecrire_post_it(fichier, texte):
|
||
try:
|
||
with open(fichier, "w") as f:
|
||
f.write(texte)
|
||
except OSError:
|
||
pass # pas grave : la page web n'affichera simplement pas l'info
|
||
|
||
|
||
def ecrire_en_cours(nom):
|
||
ecrire_post_it(FICHIER_EN_COURS, nom)
|
||
|
||
|
||
def demande_en_attente():
|
||
return os.path.exists(FICHIER_DEMANDE)
|
||
|
||
|
||
def lire_demande():
|
||
"""Renvoie la demande du serveur web (et l'efface), ou None s'il n'y en a pas."""
|
||
try:
|
||
with open(FICHIER_DEMANDE) as f:
|
||
demande = f.read().strip()
|
||
os.remove(FICHIER_DEMANDE)
|
||
return demande or None
|
||
except OSError:
|
||
return None
|
||
|
||
|
||
def traiter_demandes(dossier):
|
||
"""Joue les animations demandées depuis la page web, s'il y en a."""
|
||
demande = lire_demande()
|
||
while demande:
|
||
if demande != SUIVANTE:
|
||
chemin = os.path.join(dossier, demande)
|
||
if os.path.exists(chemin):
|
||
jouer_animation(chemin, dossier)
|
||
else:
|
||
print(" ✗ animation demandée introuvable :", demande)
|
||
demande = lire_demande()
|
||
|
||
|
||
# =============================================================================
|
||
# 6. 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)
|
||
|
||
|
||
# =============================================================================
|
||
# 7. 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()
|
||
|
||
# Quand le service est arrêté (systemctl stop, extinction du Pi), Linux
|
||
# envoie le signal « SIGTERM ». Par défaut, Python s'arrêterait net, sans
|
||
# passer par « finally » : la matrice resterait figée sur la dernière image.
|
||
# On transforme ce signal en sortie normale du programme.
|
||
signal.signal(signal.SIGTERM, lambda numero, cadre: sys.exit(0))
|
||
|
||
try:
|
||
while True: # boucle infinie : la vitrine tourne jusqu'à ce qu'on l'arrête
|
||
if not en_horaires():
|
||
ecrire_en_cours("(éteint : en dehors des 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 = [os.path.abspath(args.chemin)] if args.chemin \
|
||
else lister_animations(dossier)
|
||
if not playlist:
|
||
ecrire_en_cours("(rien à jouer)")
|
||
ecran_d_attente(dossier)
|
||
traiter_demandes(dossier)
|
||
continue
|
||
|
||
for chemin in playlist:
|
||
traiter_demandes(dossier) # « jouer maintenant » depuis le web ?
|
||
if os.path.exists(chemin): # (elle a pu être supprimée entre-temps)
|
||
jouer_animation(chemin, dossier)
|
||
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()
|
||
os.remove(FICHIER_EN_COURS)
|
||
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()
|