Files
vitrine-pixels/8x32 PI/lecteur.py
T
bachandClaude Opus 5.5 5b0e858abe 8x32 PI : vitesse de lecture, QR code et affiche, sens de l'image sur la vitrine
- 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>
2026-10-09 16:04:13 +02:00

491 lines
19 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. 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()