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>
@@ -36,6 +36,13 @@ Prenez celle du réseau sur lequel **vous** êtes :
|
|||||||
| en **Wi-Fi** (téléphone, ordinateur portable) | **`http://172.16.50.27`** |
|
| en **Wi-Fi** (téléphone, ordinateur portable) | **`http://172.16.50.27`** |
|
||||||
| par **câble** (ordinateur fixe de l'école) | `http://172.16.7.199` |
|
| par **câble** (ordinateur fixe de l'école) | `http://172.16.7.199` |
|
||||||
|
|
||||||
|
> **Le plus simple : le QR code.** Le dossier [`qrcode/`](qrcode/) contient
|
||||||
|
> le QR code de la page et une **affiche A5 à imprimer**
|
||||||
|
> ([`affiche_interface_web.pdf`](qrcode/affiche_interface_web.pdf)) : on la
|
||||||
|
> scanne avec l'appareil photo du téléphone, connecté au Wi-Fi de l'école.
|
||||||
|
> Si l'adresse du Pi change, refaites-les :
|
||||||
|
> `python3 outils/generer_qrcode.py http://NOUVELLE_ADRESSE`.
|
||||||
|
|
||||||
> **Pourquoi deux adresses ?** Une adresse, c'est comme l'adresse postale du
|
> **Pourquoi deux adresses ?** Une adresse, c'est comme l'adresse postale du
|
||||||
> Pi, mais **sur un réseau donné**. Le réseau Wi-Fi et le réseau filaire de
|
> Pi, mais **sur un réseau donné**. Le réseau Wi-Fi et le réseau filaire de
|
||||||
> l'école sont deux « villes » différentes, et l'école ne fait pas passer le
|
> l'école sont deux « villes » différentes, et l'école ne fait pas passer le
|
||||||
@@ -53,7 +60,10 @@ Prenez celle du réseau sur lequel **vous** êtes :
|
|||||||
> marchent pas : la page n'est pas sur Internet, seulement sur le réseau local.
|
> marchent pas : la page n'est pas sur Internet, seulement sur le réseau local.
|
||||||
|
|
||||||
En haut de la page, en permanence : **ce que la matrice joue en ce moment**
|
En haut de la page, en permanence : **ce que la matrice joue en ce moment**
|
||||||
(mis à jour toutes les 2 secondes) et le bouton **Suivante ⏭**.
|
(mis à jour toutes les 2 secondes), le bouton **Suivante ⏭** et le menu
|
||||||
|
**Vitesse** : ×0,5 joue toutes les animations deux fois plus lentement, ×2
|
||||||
|
deux fois plus vite. Le choix est gardé même après un redémarrage, et
|
||||||
|
s'applique à partir de l'animation suivante.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -439,8 +439,10 @@ animation, pause entre deux, horaires) et de [`matrice.py`](matrice.py) :
|
|||||||
| `LUMINOSITE` | matrice.py | `40` | de 0 à 255 ; montez en plein jour, en vitrine |
|
| `LUMINOSITE` | matrice.py | `40` | de 0 à 255 ; montez en plein jour, en vitrine |
|
||||||
| `COURANT_MAX_MA` | matrice.py | `2500` | un peu **sous** la limite de l'alimentation |
|
| `COURANT_MAX_MA` | matrice.py | `2500` | un peu **sous** la limite de l'alimentation |
|
||||||
| `ROTATION` | matrice.py | `90` | `90` = matrice debout (image 8 × 32), `0` = couchée (32 × 8) |
|
| `ROTATION` | matrice.py | `90` | `90` = matrice debout (image 8 × 32), `0` = couchée (32 × 8) |
|
||||||
| `INVERSER_X`, `INVERSER_Y` | matrice.py | `False` | sens de l'image (partie 8) |
|
| `INVERSER_X`, `INVERSER_Y` | matrice.py | `True` | sens de l'image (partie 8) ; réglés sur la matrice de la vitrine |
|
||||||
|
| `DECALAGE_X`, `DECALAGE_Y` | matrice.py | `1`, `0` | décale toute l'image d'un pixel (ici vers la droite : la colonne de gauche reste éteinte) |
|
||||||
| `GAMMA` | matrice.py | `2.2` | correction des couleurs (1.0 = aucune) |
|
| `GAMMA` | matrice.py | `2.2` | correction des couleurs (1.0 = aucune) |
|
||||||
|
| `VITESSE` | lecteur.py | `1.0` | vitesse de lecture (0.5 = deux fois plus lent) ; le menu « Vitesse » de la page web a la priorité |
|
||||||
| `DUREE_MINIMUM` | lecteur.py | `10` | une animation courte est rejouée pendant au moins 10 s |
|
| `DUREE_MINIMUM` | lecteur.py | `10` | une animation courte est rejouée pendant au moins 10 s |
|
||||||
| `HORAIRES` | lecteur.py | `None` | ex. `("09:00", "22:30")` |
|
| `HORAIRES` | lecteur.py | `None` | ex. `("09:00", "22:30")` |
|
||||||
|
|
||||||
@@ -475,6 +477,7 @@ navigateur, et on gère la vitrine sans terminal ni carte SD.
|
|||||||
| **Changer l'ordre** de lecture | ▲ ▼ (les fichiers sont renumérotés `01_`, `02_`…) |
|
| **Changer l'ordre** de lecture | ▲ ▼ (les fichiers sont renumérotés `01_`, `02_`…) |
|
||||||
| **Retirer** une animation de la boucle, sans la supprimer | ⏸ (elle passe dans « Pas jouées ») ; + pour la remettre |
|
| **Retirer** une animation de la boucle, sans la supprimer | ⏸ (elle passe dans « Pas jouées ») ; + pour la remettre |
|
||||||
| **Jouer tout de suite** une animation, ou **passer** à la suivante | ▶ et **Suivante ⏭** |
|
| **Jouer tout de suite** une animation, ou **passer** à la suivante | ▶ et **Suivante ⏭** |
|
||||||
|
| **Ralentir ou accélérer** toutes les animations | menu **Vitesse** en haut (×0,25 à ×2) |
|
||||||
| **Supprimer** | ✕ : elle va à la corbeille ; « Vider » efface pour de bon |
|
| **Supprimer** | ✕ : elle va à la corbeille ; « Vider » efface pour de bon |
|
||||||
| **Savoir ce que joue la matrice** | en haut de la page, mis à jour toutes les 2 secondes |
|
| **Savoir ce que joue la matrice** | en haut de la page, mis à jour toutes les 2 secondes |
|
||||||
|
|
||||||
@@ -490,7 +493,7 @@ réseau filaire de l'école ne communiquent pas entre eux : prenez celle de
|
|||||||
|
|
||||||
| Vous êtes connecté… | Adresse |
|
| Vous êtes connecté… | Adresse |
|
||||||
|---|---|
|
|---|---|
|
||||||
| en **Wi-Fi** (téléphone, portable) | **http://172.16.50.27** |
|
| en **Wi-Fi** (téléphone, portable) | **http://172.16.50.27**, ou scannez le QR code du dossier [`qrcode/`](qrcode/) |
|
||||||
| par **câble** | http://172.16.7.199 |
|
| par **câble** | http://172.16.7.199 |
|
||||||
|
|
||||||
Ces adresses sont distribuées par le réseau et **peuvent changer** : les
|
Ces adresses sont distribuées par le réseau et **peuvent changer** : les
|
||||||
@@ -640,6 +643,7 @@ Pour voir ce que fait le lecteur quand il tourne tout seul (mode vitrine) :
|
|||||||
| [`test_matrice.py`](test_matrice.py) | | test de branchement et d'orientation |
|
| [`test_matrice.py`](test_matrice.py) | | test de branchement et d'orientation |
|
||||||
| [`mes_animations.py`](mes_animations.py) | | les animations de l'Arduino, traduites en Python |
|
| [`mes_animations.py`](mes_animations.py) | | les animations de l'Arduino, traduites en Python |
|
||||||
| [`animations/`](animations/) | | exemples : GIF, séquence de PNG, APNG |
|
| [`animations/`](animations/) | | exemples : GIF, séquence de PNG, APNG |
|
||||||
|
| [`qrcode/`](qrcode/) | tous | QR code de la page web et affiche A5 à imprimer (refaits par `outils/generer_qrcode.py`) |
|
||||||
| [`schemas/`](schemas/) | | schémas de branchement, brochage de la puce, chaîne Krita → matrice, capture de l'interface web |
|
| [`schemas/`](schemas/) | | schémas de branchement, brochage de la puce, chaîne Krita → matrice, capture de l'interface web |
|
||||||
| `apercus/` | design | créé par `verifier_animation.py` : aperçus « façon LED » à partager |
|
| `apercus/` | design | créé par `verifier_animation.py` : aperçus « façon LED » à partager |
|
||||||
| [`krita/palette_vitrine_pixels.gpl`](krita/palette_vitrine_pixels.gpl) | design | palette de couleurs adaptée aux LED, à importer dans Krita |
|
| [`krita/palette_vitrine_pixels.gpl`](krita/palette_vitrine_pixels.gpl) | design | palette de couleurs adaptée aux LED, à importer dans Krita |
|
||||||
|
|||||||
@@ -91,6 +91,13 @@ IMAGES_PAR_SECONDE = 12
|
|||||||
# Une image fixe reste affichée pendant cette durée.
|
# Une image fixe reste affichée pendant cette durée.
|
||||||
DUREE_MINIMUM = 10
|
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).
|
# Noir entre deux animations, en secondes (0 = enchaîner directement).
|
||||||
PAUSE_ENTRE_ANIMATIONS = 0.3
|
PAUSE_ENTRE_ANIMATIONS = 0.3
|
||||||
|
|
||||||
@@ -256,8 +263,19 @@ def charger(chemin):
|
|||||||
# 4. JOUER UNE ANIMATION
|
# 4. JOUER UNE ANIMATION
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
def jouer(etapes, duree_minimum=DUREE_MINIMUM):
|
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.
|
"""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)."""
|
S'arrête plus tôt si le serveur web demande autre chose (partie 5)."""
|
||||||
debut = time.monotonic()
|
debut = time.monotonic()
|
||||||
while True:
|
while True:
|
||||||
@@ -265,7 +283,7 @@ def jouer(etapes, duree_minimum=DUREE_MINIMUM):
|
|||||||
moment = time.monotonic()
|
moment = time.monotonic()
|
||||||
matrice.montrer(image)
|
matrice.montrer(image)
|
||||||
# On retire le temps déjà passé à afficher, pour garder le rythme.
|
# On retire le temps déjà passé à afficher, pour garder le rythme.
|
||||||
if not attendre_ou_interrompre(duree - (time.monotonic() - moment)):
|
if not attendre_ou_interrompre(duree / vitesse - (time.monotonic() - moment)):
|
||||||
return
|
return
|
||||||
if time.monotonic() - debut >= duree_minimum:
|
if time.monotonic() - debut >= duree_minimum:
|
||||||
break
|
break
|
||||||
@@ -296,7 +314,7 @@ def jouer_animation(chemin, dossier):
|
|||||||
print(f" ✗ impossible de lire ce fichier ({erreur}) : on passe au suivant")
|
print(f" ✗ impossible de lire ce fichier ({erreur}) : on passe au suivant")
|
||||||
return
|
return
|
||||||
if etapes:
|
if etapes:
|
||||||
jouer(etapes)
|
jouer(etapes, vitesse=lire_vitesse_de_lecture(dossier))
|
||||||
if PAUSE_ENTRE_ANIMATIONS > 0:
|
if PAUSE_ENTRE_ANIMATIONS > 0:
|
||||||
matrice.effacer()
|
matrice.effacer()
|
||||||
matrice.montrer()
|
matrice.montrer()
|
||||||
|
|||||||
@@ -121,8 +121,16 @@ COURANT_MAX_MA = 2500
|
|||||||
# - rouge et vert inversés (gauche/droite) → INVERSER_X = True
|
# - rouge et vert inversés (gauche/droite) → INVERSER_X = True
|
||||||
# - rouge et vert en bas au lieu d'en haut → INVERSER_Y = True
|
# - rouge et vert en bas au lieu d'en haut → INVERSER_Y = True
|
||||||
# - les deux à la fois (image à l'envers) → les deux à True
|
# - les deux à la fois (image à l'envers) → les deux à True
|
||||||
INVERSER_X = False
|
INVERSER_X = True
|
||||||
INVERSER_Y = False
|
INVERSER_Y = True
|
||||||
|
|
||||||
|
# DÉCALAGE de l'image sur la matrice, en pixels (après INVERSER_X / Y).
|
||||||
|
# DECALAGE_X = 1 : toute l'image glisse d'un pixel vers la DROITE ;
|
||||||
|
# la colonne de gauche reste éteinte, la dernière colonne de l'image sort
|
||||||
|
# de la matrice. Utile si un bord de la matrice est caché dans la vitrine.
|
||||||
|
# (Valeurs négatives : vers la gauche ; DECALAGE_Y : vers le bas.)
|
||||||
|
DECALAGE_X = 1
|
||||||
|
DECALAGE_Y = 0
|
||||||
|
|
||||||
# CORRECTION GAMMA (voir partie 4). 2.2 = comme un écran d'ordinateur.
|
# CORRECTION GAMMA (voir partie 4). 2.2 = comme un écran d'ordinateur.
|
||||||
# 1.0 = pas de correction (les couleurs sombres paraîtront trop claires).
|
# 1.0 = pas de correction (les couleurs sombres paraîtront trop claires).
|
||||||
@@ -526,9 +534,18 @@ def assombrir(pourcentage):
|
|||||||
for (r, v, b) in pixels_de(toile)])
|
for (r, v, b) in pixels_de(toile)])
|
||||||
|
|
||||||
|
|
||||||
|
def decaler(image):
|
||||||
|
"""Applique DECALAGE_X / DECALAGE_Y : l'image glisse, le vide est noir."""
|
||||||
|
if DECALAGE_X == 0 and DECALAGE_Y == 0:
|
||||||
|
return image
|
||||||
|
decalee = Image.new("RGB", (LARGEUR, HAUTEUR))
|
||||||
|
decalee.paste(image, (DECALAGE_X, DECALAGE_Y))
|
||||||
|
return decalee
|
||||||
|
|
||||||
|
|
||||||
def montrer(image=None):
|
def montrer(image=None):
|
||||||
"""Affiche la toile (ou une autre image de 32 × 8) sur l'écran."""
|
"""Affiche la toile (ou une autre image de la taille de la matrice) sur l'écran."""
|
||||||
_ecran.afficher(image if image is not None else toile)
|
_ecran.afficher(decaler(image if image is not None else toile))
|
||||||
|
|
||||||
|
|
||||||
def attendre(millisecondes):
|
def attendre(millisecondes):
|
||||||
|
|||||||
@@ -0,0 +1,328 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
=============================================================================
|
||||||
|
QR CODE ET AFFICHE D'ACCÈS À L'INTERFACE WEB
|
||||||
|
Workshop « Vitrine pixels »
|
||||||
|
=============================================================================
|
||||||
|
|
||||||
|
Fabrique, dans le dossier « qrcode » :
|
||||||
|
- qrcode_interface_web.png : le QR code seul (pour un message, un diaporama) ;
|
||||||
|
- qrcode_interface_web.svg : le même, en vectoriel (pour l'imprimer en grand) ;
|
||||||
|
- affiche_interface_web.svg : une affiche A5 à imprimer et à poser à côté
|
||||||
|
de la vitrine ou dans l'atelier, avec deux
|
||||||
|
compositions inspirées des animations de
|
||||||
|
l'équipe (qrcode/sources : « cascade », « tetris ») ;
|
||||||
|
- affiche_interface_web.pdf : la même, prête à imprimer (si rsvg-convert
|
||||||
|
est installé : paquet « librsvg2-bin »).
|
||||||
|
|
||||||
|
On scanne le QR code avec l'appareil photo du téléphone : la page web de la
|
||||||
|
vitrine s'ouvre. ⚠️ Le téléphone doit être connecté au Wi-Fi de l'école
|
||||||
|
(la page n'est pas sur Internet).
|
||||||
|
|
||||||
|
L'adresse du Pi peut changer (voir INTERFACE_WEB.md) : il suffit alors de
|
||||||
|
relancer ce programme avec la nouvelle adresse :
|
||||||
|
python3 outils/generer_qrcode.py http://172.16.50.27
|
||||||
|
|
||||||
|
Il faut le logiciel « qrencode » :
|
||||||
|
sudo apt install qrencode (Debian, Ubuntu, Raspberry Pi OS)
|
||||||
|
sudo pacman -S qrencode (Arch)
|
||||||
|
brew install qrencode (macOS)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
ICI = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
SORTIE = os.path.join(ICI, "qrcode")
|
||||||
|
ADRESSE_PAR_DEFAUT = "http://172.16.50.27"
|
||||||
|
|
||||||
|
|
||||||
|
def modules_du_qrcode(texte):
|
||||||
|
"""Demande à qrencode le QR code en texte : une ligne par rangée,
|
||||||
|
« # » = case noire. Renvoie une liste de lignes de booléens."""
|
||||||
|
# -t ASCII : dessin en caractères (2 caractères par case) ; -m 0 : sans marge ;
|
||||||
|
# -l M : correction d'erreur moyenne (le code reste lisible s'il est un peu abîmé).
|
||||||
|
resultat = subprocess.run(["qrencode", "-t", "ASCII", "-m", "0", "-l", "M", texte],
|
||||||
|
capture_output=True, text=True, check=True)
|
||||||
|
lignes = [l for l in resultat.stdout.splitlines() if l.strip()]
|
||||||
|
return [[ligne[i] == "#" for i in range(0, len(ligne), 2)] for ligne in lignes]
|
||||||
|
|
||||||
|
|
||||||
|
def cases_svg(modules, x0, y0, taille):
|
||||||
|
"""Les cases noires du QR code, en rectangles SVG."""
|
||||||
|
return "".join(
|
||||||
|
f'<rect x="{x0 + x * taille:.2f}" y="{y0 + y * taille:.2f}" '
|
||||||
|
f'width="{taille:.2f}" height="{taille:.2f}"/>'
|
||||||
|
for y, ligne in enumerate(modules) for x, noir in enumerate(ligne) if noir)
|
||||||
|
|
||||||
|
|
||||||
|
def qrcode_svg(modules, marge=4):
|
||||||
|
n = len(modules) + 2 * marge
|
||||||
|
return (f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {n} {n}" '
|
||||||
|
f'width="{n * 10}" height="{n * 10}" shape-rendering="crispEdges">'
|
||||||
|
f'<rect width="{n}" height="{n}" fill="#fff"/>'
|
||||||
|
f'<g fill="#000">{cases_svg(modules, marge, marge, 1)}</g></svg>\n')
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# L'AFFICHE : deux compositions inspirées des animations de l'équipe
|
||||||
|
# =============================================================================
|
||||||
|
#
|
||||||
|
# En haut, une coulée inspirée de « cascade » : un liquide bleu glacier qui
|
||||||
|
# déborde du haut de la page, en strates séparées par de l'écume, avec des
|
||||||
|
# stalactites qui gouttent. En bas, une partie de « tetris » : des pièces de
|
||||||
|
# couleurs vives empilées sur toute la largeur, deux pièces encore en chute.
|
||||||
|
#
|
||||||
|
# Les deux compositions sont dessinées sur une grille de « LED » de 48 colonnes,
|
||||||
|
# et leurs couleurs sont prises dans les fichiers de l'équipe (qrcode/sources).
|
||||||
|
# Le hasard est « graine fixe » : on obtient la même affiche à chaque fois.
|
||||||
|
|
||||||
|
import random
|
||||||
|
|
||||||
|
SOURCES = os.path.join(SORTIE, "sources")
|
||||||
|
COLONNES = 48 # largeur de la grille de pixels de l'affiche
|
||||||
|
PAS = 148 / COLONNES # taille d'un pixel, en mm (≈ 3,1)
|
||||||
|
REMPLISSAGE = 0.88 # un pixel = un carré un peu plus petit que le pas
|
||||||
|
|
||||||
|
BLEUS_CASCADE = [(170, 199, 254), (119, 179, 254), (118, 218, 254)]
|
||||||
|
COULEURS_TETRIS = [(255, 38, 38), (255, 127, 0), (255, 214, 0), (0, 214, 60),
|
||||||
|
(0, 220, 230), (255, 0, 255)]
|
||||||
|
|
||||||
|
|
||||||
|
def couleurs_de(chemin, garder):
|
||||||
|
"""Les couleurs d'une animation de l'équipe, triées par fréquence,
|
||||||
|
filtrées par la fonction « garder ». [] si le fichier est absent."""
|
||||||
|
try:
|
||||||
|
sys.path.insert(0, ICI)
|
||||||
|
import lecteur # noqa: E402
|
||||||
|
compte = {}
|
||||||
|
for image, _ in lecteur.charger(chemin):
|
||||||
|
for c in lecteur.matrice.pixels_de(image):
|
||||||
|
compte[c] = compte.get(c, 0) + 1
|
||||||
|
return [c for c, _ in sorted(compte.items(), key=lambda kv: -kv[1]) if garder(c)]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def hexa(c):
|
||||||
|
return f"#{c[0]:02x}{c[1]:02x}{c[2]:02x}"
|
||||||
|
|
||||||
|
|
||||||
|
def pixel(x, y, couleur, y0=0.0):
|
||||||
|
"""Un pixel de la grille de l'affiche, en colonne x, rangée y (depuis y0 mm)."""
|
||||||
|
marge = PAS * (1 - REMPLISSAGE) / 2
|
||||||
|
return (f'<rect x="{x * PAS + marge:.2f}" y="{y0 + y * PAS + marge:.2f}" '
|
||||||
|
f'width="{PAS * REMPLISSAGE:.2f}" height="{PAS * REMPLISSAGE:.2f}" rx="0.35" '
|
||||||
|
f'fill="{hexa(couleur) if isinstance(couleur, tuple) else couleur}"/>')
|
||||||
|
|
||||||
|
|
||||||
|
def composition_cascade(bleus):
|
||||||
|
"""Le liquide qui coule du haut de la page (sur fond blanc)."""
|
||||||
|
import math
|
||||||
|
hasard = random.Random(11)
|
||||||
|
clair, moyen, vif = (bleus + BLEUS_CASCADE)[:3]
|
||||||
|
cases = {}
|
||||||
|
# Profondeur du liquide dans chaque colonne : une ondulation lente.
|
||||||
|
profondeur = [int(5.2 + 1.6 * math.sin(x / 6.0) + 0.9 * math.sin(x / 2.7 + 2))
|
||||||
|
for x in range(COLONNES)]
|
||||||
|
for x in range(COLONNES):
|
||||||
|
for y in range(profondeur[x] + 1):
|
||||||
|
distance = profondeur[x] - y # distance au bord du liquide
|
||||||
|
# Deux traits d'écume blancs (des « trous ») qui suivent le bord,
|
||||||
|
# interrompus de temps en temps, comme dans l'animation.
|
||||||
|
if distance in (2, 5) and hasard.random() < 0.8:
|
||||||
|
continue
|
||||||
|
cases[(x, y)] = clair if distance <= 1 else moyen if distance <= 4 else vif
|
||||||
|
# Les coulures : des filets de 1 ou 2 pixels de large, terminés par une goutte.
|
||||||
|
x = hasard.randint(0, 2)
|
||||||
|
while x < COLONNES:
|
||||||
|
largeur = hasard.choice([1, 1, 2])
|
||||||
|
longueur = hasard.choice([1, 2, 2, 3, 4, 5])
|
||||||
|
haut = max(profondeur[min(x + i, COLONNES - 1)] for i in range(largeur)) + 1
|
||||||
|
for i in range(largeur):
|
||||||
|
for y in range(haut, haut + longueur):
|
||||||
|
cases[(x + i, y)] = clair
|
||||||
|
if longueur >= 3: # la goutte, plus large, en bas
|
||||||
|
for i in range(-1, largeur + 1):
|
||||||
|
cases[(x + i, haut + longueur)] = clair
|
||||||
|
for i in range(largeur):
|
||||||
|
cases[(x + i, haut + longueur + 1)] = clair
|
||||||
|
if hasard.random() < 0.35: # une goutte déjà détachée
|
||||||
|
cases[(x, haut + longueur + hasard.randint(3, 4))] = moyen
|
||||||
|
x += largeur + hasard.randint(2, 5)
|
||||||
|
return "".join(pixel(x, y, c) for (x, y), c in cases.items() if 0 <= x < COLONNES)
|
||||||
|
|
||||||
|
|
||||||
|
PIECES = { # les 7 pièces de Tetris, (colonne, rangée) de chaque carré
|
||||||
|
"I": [(0, 0), (1, 0), (2, 0), (3, 0)], "O": [(0, 0), (1, 0), (0, 1), (1, 1)],
|
||||||
|
"T": [(0, 0), (1, 0), (2, 0), (1, 1)], "S": [(1, 0), (2, 0), (0, 1), (1, 1)],
|
||||||
|
"Z": [(0, 0), (1, 0), (1, 1), (2, 1)], "J": [(0, 0), (0, 1), (1, 1), (2, 1)],
|
||||||
|
"L": [(2, 0), (0, 1), (1, 1), (2, 1)],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def tourner(cases, fois):
|
||||||
|
for _ in range(fois):
|
||||||
|
cases = [(-y, x) for x, y in cases]
|
||||||
|
mx, my = min(x for x, _ in cases), min(y for _, y in cases)
|
||||||
|
return [(x - mx, y - my) for x, y in cases]
|
||||||
|
|
||||||
|
|
||||||
|
def bloc(x, y, couleur, y0):
|
||||||
|
"""Un carré de Tetris : la couleur, avec un petit reflet clair en haut à gauche."""
|
||||||
|
clair = tuple(int(c + (255 - c) * 0.55) for c in couleur)
|
||||||
|
marge = PAS * (1 - REMPLISSAGE) / 2
|
||||||
|
cote = PAS * REMPLISSAGE
|
||||||
|
gx, gy = x * PAS + marge, y0 + y * PAS + marge
|
||||||
|
return (f'<rect x="{gx:.2f}" y="{gy:.2f}" width="{cote:.2f}" height="{cote:.2f}" '
|
||||||
|
f'rx="0.3" fill="{hexa(couleur)}"/>'
|
||||||
|
f'<rect x="{gx + cote * 0.18:.2f}" y="{gy + cote * 0.18:.2f}" width="{cote * 0.28:.2f}" '
|
||||||
|
f'height="{cote * 0.28:.2f}" fill="{hexa(clair)}"/>')
|
||||||
|
|
||||||
|
|
||||||
|
def composition_tetris(couleurs, y_bas):
|
||||||
|
"""Une partie de Tetris sur toute la largeur, le bas de la pile en y_bas (mm)."""
|
||||||
|
hasard = random.Random(5)
|
||||||
|
hauteur_max = 7
|
||||||
|
plateau = {} # (x, y) → couleur ; y = 0 en bas
|
||||||
|
|
||||||
|
def poser(forme, x0, y):
|
||||||
|
return [(x0 + x, y + dy) for x, dy in forme]
|
||||||
|
|
||||||
|
def atterrissage(forme, x0):
|
||||||
|
y = hauteur_max + 4
|
||||||
|
while y > 0 and all(c not in plateau for c in poser(forme, x0, y - 1)):
|
||||||
|
y -= 1
|
||||||
|
return y
|
||||||
|
|
||||||
|
for _ in range(400):
|
||||||
|
forme = tourner(PIECES[hasard.choice(list(PIECES))], hasard.randint(0, 3))
|
||||||
|
forme = [(x, max(y for _, y in forme) - y) for x, y in forme] # y vers le haut
|
||||||
|
largeur = max(x for x, _ in forme) + 1
|
||||||
|
essais = []
|
||||||
|
for x0 in range(COLONNES - largeur + 1):
|
||||||
|
y = atterrissage(forme, x0)
|
||||||
|
cases = poser(forme, x0, y)
|
||||||
|
# Les trous laissés sous la pièce (un bon joueur les évite).
|
||||||
|
trous = sum(1 for (cx, cy) in cases
|
||||||
|
if cy > 0 and (cx, cy - 1) not in plateau and (cx, cy - 1) not in cases)
|
||||||
|
essais.append((y + 3 * trous + hasard.random() * 1.5, x0, cases))
|
||||||
|
_, x0, cases = min(essais)
|
||||||
|
# L'horizon reste dentelé : plus haut au centre-gauche, plus bas ailleurs.
|
||||||
|
limite = hauteur_max - (2 if (x0 // 6) % 3 == 1 else 0)
|
||||||
|
if max(cy for _, cy in cases) >= limite:
|
||||||
|
continue
|
||||||
|
# Une couleur différente de celles des pièces qui la touchent.
|
||||||
|
voisines = {plateau[(cx + dx, cy + dy)] for cx, cy in cases
|
||||||
|
for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1))
|
||||||
|
if (cx + dx, cy + dy) in plateau}
|
||||||
|
libres = [c for c in couleurs if c not in voisines] or couleurs
|
||||||
|
couleur = hasard.choice(libres)
|
||||||
|
for c in cases:
|
||||||
|
plateau[c] = couleur
|
||||||
|
|
||||||
|
morceaux = [bloc(x, -y - 1, c, y_bas) for (x, y), c in plateau.items()]
|
||||||
|
# Deux pièces encore en train de tomber, au-dessus de la pile.
|
||||||
|
sommet = max(y for _, y in plateau) + 1
|
||||||
|
for nom, x0, au_dessus, tour, couleur in (("T", 4, 3, 2, couleurs[0]),
|
||||||
|
("L", 41, 4, 1, couleurs[-1])):
|
||||||
|
for x, y in tourner(PIECES[nom], tour):
|
||||||
|
morceaux.append(bloc(x0 + x, -(sommet + au_dessus) + y, couleur, y_bas))
|
||||||
|
return "".join(morceaux)
|
||||||
|
|
||||||
|
|
||||||
|
def titre_en_pixels(texte, y0, taille, couleurs, depart=0):
|
||||||
|
"""Écrit un texte avec la police 5 × 7 des exemples, centré,
|
||||||
|
une couleur par lettre (on fait le tour de la liste « couleurs »)."""
|
||||||
|
sys.path.insert(0, os.path.join(ICI, "outils"))
|
||||||
|
from generer_exemples import POLICE # noqa: E402
|
||||||
|
largeur = sum(len(POLICE[c][0]) + 1 for c in texte) - 1
|
||||||
|
x = (148 - largeur * taille) / 2
|
||||||
|
morceaux, n = [], depart
|
||||||
|
for c in texte:
|
||||||
|
couleur = hexa(couleurs[n % len(couleurs)])
|
||||||
|
if c != " ":
|
||||||
|
n += 1
|
||||||
|
for ligne, motif in enumerate(POLICE[c]):
|
||||||
|
for dx, p in enumerate(motif):
|
||||||
|
if p == "#":
|
||||||
|
morceaux.append(f'<rect x="{x + dx * taille:.2f}" y="{y0 + ligne * taille:.2f}" '
|
||||||
|
f'width="{taille * 0.88:.2f}" height="{taille * 0.88:.2f}" '
|
||||||
|
f'rx="0.2" fill="{couleur}"/>')
|
||||||
|
x += (len(POLICE[c][0]) + 1) * taille
|
||||||
|
return "".join(morceaux)
|
||||||
|
|
||||||
|
|
||||||
|
def affiche_svg(modules, adresse):
|
||||||
|
"""Affiche A5 (148 × 210 mm)."""
|
||||||
|
l, h = 148, 210
|
||||||
|
encre, gris = "#141414", "#6b665d"
|
||||||
|
|
||||||
|
bleus = couleurs_de(os.path.join(SOURCES, "cascade.gif"),
|
||||||
|
lambda c: c[2] > 200 and c[0] < 200) # les bleus
|
||||||
|
vives = couleurs_de(os.path.join(SOURCES, "tetris"),
|
||||||
|
lambda c: max(c) - min(c) > 180) # les saturées
|
||||||
|
# Une couleur par teinte (l'animation en contient des nuances très proches).
|
||||||
|
tetris = []
|
||||||
|
for c in vives + COULEURS_TETRIS:
|
||||||
|
if all(sum(abs(a - b) for a, b in zip(c, t)) > 120 for t in tetris):
|
||||||
|
tetris.append(c)
|
||||||
|
|
||||||
|
y_titre = 49
|
||||||
|
cote_qr = 56
|
||||||
|
x_qr, y_qr = (l - cote_qr) / 2, y_titre + 37
|
||||||
|
taille = cote_qr / len(modules)
|
||||||
|
y_texte = y_qr + cote_qr + 12
|
||||||
|
etapes = ["1. Connectez-vous au Wi-Fi de l'école.",
|
||||||
|
"2. Visez le QR code avec l'appareil photo.",
|
||||||
|
"3. Déposez vos animations, choisissez l'ordre."]
|
||||||
|
textes = (f'<text x="{l / 2}" y="{y_texte}" font-size="7" font-weight="bold" fill="{encre}" '
|
||||||
|
f'text-anchor="middle" font-family="Menlo, Consolas, monospace">'
|
||||||
|
f'{adresse.replace("http://", "")}</text>'
|
||||||
|
+ "".join(f'<text x="{l / 2}" y="{y_texte + 8 + i * 6}" font-size="4.2" '
|
||||||
|
f'fill="{encre}" text-anchor="middle">{e}</text>'
|
||||||
|
for i, e in enumerate(etapes))
|
||||||
|
+ f'<text x="{l / 2}" y="{y_texte + 31}" font-size="3.2" fill="{gris}" '
|
||||||
|
f'text-anchor="middle">Uniquement sur le Wi-Fi de l\'école (pas en 4G / 5G).</text>')
|
||||||
|
|
||||||
|
return f"""<svg xmlns="http://www.w3.org/2000/svg" width="{l}mm" height="{h}mm" viewBox="0 0 {l} {h}"
|
||||||
|
font-family="Helvetica, Arial, sans-serif">
|
||||||
|
<title>Vitrine pixels : accès à l'interface web</title>
|
||||||
|
<rect width="{l}" height="{h}" fill="#fff"/>
|
||||||
|
{composition_cascade(bleus)}
|
||||||
|
{titre_en_pixels("VITRINE", y_titre, 2.0, tetris)}
|
||||||
|
{titre_en_pixels("PIXELS", y_titre + 16, 2.0, tetris, depart=1)}
|
||||||
|
<rect x="{x_qr - 4}" y="{y_qr - 4}" width="{cote_qr + 8}" height="{cote_qr + 8}" fill="#fff"/>
|
||||||
|
<g fill="#000" shape-rendering="crispEdges">{cases_svg(modules, x_qr, y_qr, taille)}</g>
|
||||||
|
{textes}
|
||||||
|
{composition_tetris(tetris, h)}
|
||||||
|
</svg>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
adresse = sys.argv[1] if len(sys.argv) > 1 else ADRESSE_PAR_DEFAUT
|
||||||
|
if not adresse.startswith("http"):
|
||||||
|
adresse = "http://" + adresse
|
||||||
|
os.makedirs(SORTIE, exist_ok=True)
|
||||||
|
|
||||||
|
modules = modules_du_qrcode(adresse)
|
||||||
|
with open(os.path.join(SORTIE, "qrcode_interface_web.svg"), "w") as f:
|
||||||
|
f.write(qrcode_svg(modules))
|
||||||
|
with open(os.path.join(SORTIE, "affiche_interface_web.svg"), "w") as f:
|
||||||
|
f.write(affiche_svg(modules, adresse))
|
||||||
|
# Le PNG, directement par qrencode : 12 pixels par case, marge de 4 cases.
|
||||||
|
subprocess.run(["qrencode", "-o", os.path.join(SORTIE, "qrcode_interface_web.png"),
|
||||||
|
"-s", "12", "-m", "4", "-l", "M", adresse], check=True)
|
||||||
|
# Le PDF de l'affiche, si l'outil de conversion est là.
|
||||||
|
if shutil.which("rsvg-convert"):
|
||||||
|
subprocess.run(["rsvg-convert", "-f", "pdf",
|
||||||
|
"-o", os.path.join(SORTIE, "affiche_interface_web.pdf"),
|
||||||
|
os.path.join(SORTIE, "affiche_interface_web.svg")], check=True)
|
||||||
|
print(f"QR code pour {adresse} écrit dans {SORTIE}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 449 B |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
@@ -0,0 +1 @@
|
|||||||
|
10
|
||||||
|
After Width: | Height: | Size: 161 B |
|
After Width: | Height: | Size: 201 B |
|
After Width: | Height: | Size: 148 B |
|
After Width: | Height: | Size: 150 B |
|
After Width: | Height: | Size: 149 B |
|
After Width: | Height: | Size: 154 B |
|
After Width: | Height: | Size: 158 B |
|
After Width: | Height: | Size: 152 B |
|
After Width: | Height: | Size: 153 B |
|
After Width: | Height: | Size: 162 B |
|
After Width: | Height: | Size: 150 B |
|
After Width: | Height: | Size: 152 B |
|
After Width: | Height: | Size: 145 B |
|
After Width: | Height: | Size: 151 B |
|
After Width: | Height: | Size: 154 B |
|
After Width: | Height: | Size: 149 B |
|
After Width: | Height: | Size: 144 B |
|
After Width: | Height: | Size: 138 B |
|
After Width: | Height: | Size: 154 B |
|
After Width: | Height: | Size: 181 B |
|
After Width: | Height: | Size: 182 B |
|
After Width: | Height: | Size: 178 B |
|
After Width: | Height: | Size: 187 B |
|
After Width: | Height: | Size: 184 B |
|
After Width: | Height: | Size: 181 B |
|
After Width: | Height: | Size: 176 B |
|
After Width: | Height: | Size: 185 B |
|
After Width: | Height: | Size: 180 B |
|
After Width: | Height: | Size: 179 B |
|
After Width: | Height: | Size: 175 B |
|
After Width: | Height: | Size: 175 B |
|
After Width: | Height: | Size: 174 B |
|
After Width: | Height: | Size: 175 B |
|
After Width: | Height: | Size: 177 B |
|
After Width: | Height: | Size: 174 B |
|
After Width: | Height: | Size: 169 B |
|
After Width: | Height: | Size: 169 B |
|
After Width: | Height: | Size: 189 B |
|
After Width: | Height: | Size: 182 B |
|
After Width: | Height: | Size: 186 B |
|
After Width: | Height: | Size: 181 B |
|
After Width: | Height: | Size: 182 B |
|
After Width: | Height: | Size: 178 B |
|
After Width: | Height: | Size: 178 B |
|
After Width: | Height: | Size: 179 B |
|
After Width: | Height: | Size: 179 B |
|
After Width: | Height: | Size: 205 B |
|
After Width: | Height: | Size: 201 B |
|
After Width: | Height: | Size: 195 B |
|
After Width: | Height: | Size: 197 B |
|
After Width: | Height: | Size: 217 B |
|
After Width: | Height: | Size: 220 B |
|
After Width: | Height: | Size: 216 B |
|
After Width: | Height: | Size: 222 B |
|
After Width: | Height: | Size: 223 B |
|
After Width: | Height: | Size: 224 B |
|
After Width: | Height: | Size: 219 B |
|
After Width: | Height: | Size: 219 B |
|
After Width: | Height: | Size: 218 B |
|
After Width: | Height: | Size: 218 B |
|
After Width: | Height: | Size: 210 B |
|
After Width: | Height: | Size: 207 B |
|
After Width: | Height: | Size: 207 B |
|
After Width: | Height: | Size: 207 B |
|
After Width: | Height: | Size: 221 B |
|
After Width: | Height: | Size: 222 B |
|
After Width: | Height: | Size: 227 B |
|
After Width: | Height: | Size: 226 B |
|
After Width: | Height: | Size: 230 B |
|
After Width: | Height: | Size: 226 B |
|
After Width: | Height: | Size: 226 B |
|
After Width: | Height: | Size: 223 B |
|
After Width: | Height: | Size: 230 B |
|
After Width: | Height: | Size: 235 B |
|
After Width: | Height: | Size: 234 B |
|
After Width: | Height: | Size: 219 B |
|
After Width: | Height: | Size: 219 B |
|
After Width: | Height: | Size: 223 B |
|
After Width: | Height: | Size: 222 B |
|
After Width: | Height: | Size: 214 B |
|
After Width: | Height: | Size: 214 B |
|
After Width: | Height: | Size: 246 B |
|
After Width: | Height: | Size: 233 B |
|
After Width: | Height: | Size: 238 B |
|
After Width: | Height: | Size: 236 B |
|
After Width: | Height: | Size: 234 B |
|
After Width: | Height: | Size: 230 B |
|
After Width: | Height: | Size: 230 B |
|
After Width: | Height: | Size: 233 B |