#!/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'' 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'' f'' f'{cases_svg(modules, marge, marge, 1)}\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'') 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'' f'') 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'') 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'' f'{adresse.replace("http://", "")}' + "".join(f'{e}' for i, e in enumerate(etapes)) + f'Uniquement sur le Wi-Fi de l\'école (pas en 4G / 5G).') return f""" Vitrine pixels : accès à l'interface web {composition_cascade(bleus)} {titre_en_pixels("VITRINE", y_titre, 2.0, tetris)} {titre_en_pixels("PIXELS", y_titre + 16, 2.0, tetris, depart=1)} {cases_svg(modules, x_qr, y_qr, taille)} {textes} {composition_tetris(tetris, h)} """ 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()