188 lines
7.5 KiB
Python
Executable File
188 lines
7.5 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""
|
||
=============================================================================
|
||
VÉRIFIER UNE ANIMATION avant de l'envoyer sur la matrice
|
||
Workshop « Vitrine pixels »
|
||
=============================================================================
|
||
|
||
Pour l'équipe design : ce programme examine un export de Krita et dit
|
||
s'il est prêt pour la matrice. Il fonctionne sur n'importe quel ordinateur
|
||
(il suffit de Python et de Pillow), sans la matrice.
|
||
|
||
Usage :
|
||
python3 outils/verifier_animation.py mon_animation.gif
|
||
python3 outils/verifier_animation.py dossier_de_png/
|
||
python3 outils/verifier_animation.py animations/ (tout le dossier)
|
||
|
||
Il affiche un petit rapport :
|
||
- taille de l'image, nombre d'images, durée, vitesse ;
|
||
- nombre de couleurs (beaucoup de couleurs = souvent de l'anticrénelage,
|
||
ces pixels « flous » qui adoucissent les bords dans Krita) ;
|
||
- pixels trop sombres, qui seront ÉTEINTS sur la matrice ;
|
||
- courant consommé, pour vérifier que l'alimentation suit.
|
||
|
||
Et il fabrique, dans le dossier « apercus », deux APERÇUS « façon LED » :
|
||
- mon_animation_apercu.png : planche avec toutes les images ;
|
||
- mon_animation_apercu.gif : l'animation agrandie, à partager.
|
||
(Pas dans le dossier des animations : le lecteur les jouerait aussi !)
|
||
"""
|
||
|
||
import os
|
||
import sys
|
||
|
||
from PIL import Image, ImageDraw
|
||
|
||
# On réutilise le code du lecteur et du moteur (dossier parent).
|
||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||
import lecteur # noqa: E402
|
||
from matrice import LARGEUR, HAUTEUR, TABLE_GAMMA, LUMINOSITE, courant_estime_ma, COURANT_MAX_MA, pixels_de # noqa: E402
|
||
|
||
DOSSIER_APERCUS = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "apercus")
|
||
TAILLE_LED = 14 # taille d'un pixel dans l'aperçu, en points
|
||
MAX_PLANCHE = 48 # nombre maximum d'images sur la planche
|
||
|
||
|
||
def pixel_eteint_sur_matrice(r, v, b):
|
||
"""Vrai si ce pixel, allumé dans l'image, sera éteint sur la matrice
|
||
(une fois la correction gamma et la luminosité appliquées)."""
|
||
if (r, v, b) == (0, 0, 0):
|
||
return False
|
||
return all(TABLE_GAMMA[c] * LUMINOSITE // 255 == 0 for c in (r, v, b))
|
||
|
||
|
||
def dessiner_leds(image, taille=TAILLE_LED):
|
||
"""Agrandit une image 32 × 8 en dessinant un rond par pixel."""
|
||
sortie = Image.new("RGB", (LARGEUR * taille, HAUTEUR * taille), (12, 12, 12))
|
||
crayon = ImageDraw.Draw(sortie)
|
||
for y in range(HAUTEUR):
|
||
for x in range(LARGEUR):
|
||
couleur = image.getpixel((x, y))
|
||
if couleur == (0, 0, 0):
|
||
couleur = (30, 30, 30) # LED éteinte : gris très sombre
|
||
marge = max(1, taille // 7)
|
||
crayon.ellipse((x * taille + marge, y * taille + marge,
|
||
(x + 1) * taille - marge, (y + 1) * taille - marge), fill=couleur)
|
||
return sortie
|
||
|
||
|
||
def verifier(chemin):
|
||
nom = os.path.basename(os.path.normpath(chemin))
|
||
print("=" * 70)
|
||
print(nom)
|
||
print("=" * 70)
|
||
|
||
# Taille d'origine (avant adaptation par le lecteur).
|
||
if os.path.isdir(chemin):
|
||
fichiers = lecteur.images_du_dossier(chemin)
|
||
if not fichiers:
|
||
print(" ✗ aucun fichier image dans ce dossier")
|
||
return
|
||
with Image.open(fichiers[0]) as premiere:
|
||
taille = premiere.size
|
||
print(f" Type : séquence de {len(fichiers)} fichiers "
|
||
f"({lecteur.lire_vitesse(chemin):g} images/s)")
|
||
else:
|
||
with Image.open(chemin) as fichier:
|
||
taille = fichier.size
|
||
print(f" Type : {fichier.format}, "
|
||
f"{'animé' if getattr(fichier, 'is_animated', False) else 'image fixe'}")
|
||
|
||
etapes = lecteur.charger(chemin)
|
||
duree = sum(d for _, d in etapes)
|
||
|
||
# --- Taille ---
|
||
if taille == (LARGEUR, HAUTEUR):
|
||
print(f" Taille : {taille[0]} × {taille[1]} ✓")
|
||
elif taille[0] % LARGEUR == 0 and taille[0] // LARGEUR == taille[1] // HAUTEUR \
|
||
and taille[1] % HAUTEUR == 0:
|
||
print(f" Taille : {taille[0]} × {taille[1]} ✓ (agrandissement × "
|
||
f"{taille[0] // LARGEUR}, sera réduit)")
|
||
else:
|
||
print(f" Taille : {taille[0]} × {taille[1]} ⚠ attendu {LARGEUR} × {HAUTEUR} "
|
||
"(l'image sera centrée et coupée)")
|
||
|
||
# --- Durée ---
|
||
if len(etapes) > 1:
|
||
print(f" Images : {len(etapes)}, durée {duree:.2f} s "
|
||
f"(≈ {len(etapes) / duree:.1f} images/s)")
|
||
else:
|
||
print(" Images : 1 (image fixe)")
|
||
|
||
# --- Couleurs ---
|
||
couleurs = set()
|
||
trop_sombres = 0
|
||
for image, _ in etapes:
|
||
for c in pixels_de(image):
|
||
couleurs.add(c)
|
||
for c in couleurs:
|
||
if pixel_eteint_sur_matrice(*c):
|
||
trop_sombres += 1
|
||
alerte = " ⚠ beaucoup ! anticrénelage ou dégradé flou ?" if len(couleurs) > 64 else " ✓"
|
||
print(f" Couleurs : {len(couleurs)} différentes{alerte}")
|
||
if trop_sombres:
|
||
print(f" Trop sombre : {trop_sombres} couleur(s) seront ÉTEINTES sur la matrice "
|
||
f"(à luminosité {LUMINOSITE}) ⚠")
|
||
else:
|
||
print(" Trop sombre : aucune ✓")
|
||
|
||
# --- Courant ---
|
||
courants = [courant_estime_ma(image) for image, _ in etapes]
|
||
maxi = max(courants)
|
||
moyen = sum(courants) / len(courants)
|
||
limite = " ⚠ limité par le logiciel (image assombrie)" if maxi >= COURANT_MAX_MA - 1 else " ✓"
|
||
print(f" Courant : moyen {moyen / 1000:.2f} A, maximum {maxi / 1000:.2f} A{limite}")
|
||
|
||
# --- Aperçus ---
|
||
os.makedirs(DOSSIER_APERCUS, exist_ok=True)
|
||
base = os.path.join(DOSSIER_APERCUS, os.path.splitext(nom)[0] + "_apercu")
|
||
images = [dessiner_leds(image) for image, _ in etapes]
|
||
|
||
# Planche : les images les unes sous les autres, numérotées.
|
||
montrees = images[:MAX_PLANCHE]
|
||
l, h = images[0].size
|
||
planche = Image.new("RGB", (l + 50, len(montrees) * (h + 6)), (255, 255, 255))
|
||
crayon = ImageDraw.Draw(planche)
|
||
for i, im in enumerate(montrees):
|
||
planche.paste(im, (50, i * (h + 6)))
|
||
crayon.text((6, i * (h + 6) + h // 2 - 6), str(i + 1), fill=(0, 0, 0))
|
||
planche.save(base + ".png")
|
||
print(f" Aperçu : {base}.png", end="")
|
||
|
||
if len(images) > 1:
|
||
durees = [max(20, round(d * 1000)) for _, d in etapes]
|
||
images[0].save(base + ".gif", save_all=True, append_images=images[1:],
|
||
duration=durees, loop=0)
|
||
print(f" et {base}.gif")
|
||
else:
|
||
print()
|
||
|
||
|
||
def est_sequence(dossier):
|
||
"""Vrai si le dossier ne contient que des PNG (et aucun sous-dossier)."""
|
||
noms = [n for n in os.listdir(dossier) if not lecteur.est_cache(n)
|
||
and n != "images_par_seconde.txt"]
|
||
return bool(noms) and all(n.lower().endswith(".png") and
|
||
os.path.isfile(os.path.join(dossier, n)) for n in noms)
|
||
|
||
|
||
def main():
|
||
if len(sys.argv) < 2:
|
||
print(__doc__)
|
||
sys.exit(1)
|
||
for chemin in sys.argv[1:]:
|
||
# Un dossier peut être une SÉQUENCE (que des PNG, pas de sous-dossier)
|
||
# ou un dossier d'animations ENTIER : on vérifie alors chaque élément.
|
||
if os.path.isdir(chemin) and not est_sequence(chemin):
|
||
cibles = lecteur.lister_animations(chemin)
|
||
else:
|
||
cibles = [chemin]
|
||
for cible in cibles:
|
||
try:
|
||
verifier(cible)
|
||
except Exception as erreur:
|
||
print(f" ✗ impossible de lire {cible} : {erreur}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|