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>
This commit is contained in:
bach
2026-10-09 16:04:13 +02:00
co-authored by Claude Opus 5.5
parent 156f9ce438
commit 5b0e858abe
135 changed files with 434 additions and 11 deletions
+328
View File
@@ -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()