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