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
+11 -1
View File
@@ -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.
--- ---
+6 -2
View File
@@ -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 |
+21 -3
View File
@@ -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()
+21 -4
View File
@@ -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):
+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()
Binary file not shown.
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 449 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

@@ -0,0 +1 @@
10
Binary file not shown.

After

Width:  |  Height:  |  Size: 161 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 B

Some files were not shown because too many files have changed in this diff Show More