Files
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

329 lines
15 KiB
Python
Raw Permalink 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.
#!/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()