#!/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 = 24 # 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 : toutes les images, numérotées. Matrice couchée : les unes sous # les autres ; matrice debout : côte à côte, comme une bande dessinée. montrees = images[:MAX_PLANCHE] l, h = images[0].size if h > l: planche = Image.new("RGB", (len(montrees) * (l + 8), h + 24), (255, 255, 255)) crayon = ImageDraw.Draw(planche) for i, im in enumerate(montrees): planche.paste(im, (i * (l + 8), 24)) crayon.text((i * (l + 8) + l // 2 - 6, 6), str(i + 1), fill=(0, 0, 0)) else: 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()