Alexandre Pariseau
Nouveaux cadres portrait (noms sans underscore) + fenetre recalculee (588x588) + resolution de cadre par normalisation
56d6441
Raw
History Blame Contribute Delete
13.5 kB
"""
Trouve mon papillon 🦋 / Find my Butterfly 🦋
Photobooth de l'Insectarium — Espace pour la vie Montréal.
Le visiteur prend un selfie. On compare son visage aux 14 papillons vedettes
de l'expo "Lepidoptera — Éloge aux papillons" et on l'insère dans le cadre du
papillon qui lui ressemble le plus.
Les noms (français / anglais / latin) et la photo du papillon sont déjà
intégrés dans chaque cadre PNG du dossier Cadres_photobooth/.
"""
import base64
import csv
import io
import os
import tempfile
import gradio as gr
import numpy as np
import torch
from PIL import Image
from transformers import AutoImageProcessor, AutoModel
# --------------------------------------------------------------------------
# Configuration
# --------------------------------------------------------------------------
MODEL_CKPT = "sasha/autotrain-butterfly-similarity-2490576840"
CSV_PATH = "Sp_et_photos.csv"
PHOTOS_DIR = "Find_butterfly_2026_Best" # photos de référence (1 par espèce)
FRAMES_DIR = "Cadres_photobooth" # cadres transparents (1 par espèce)
# Fenêtre transparente (le cercle) où apparaît le selfie, dans l'espace 2000x1545.
# Mesurée sur les cadres (bbox des pixels transparents) — à mettre à jour si les
# templates changent : voir scripts/alpha bbox.
WINDOW = (476, 117, 1064, 705) # left, top, right, bottom
device = "cuda" if torch.cuda.is_available() else "cpu"
# --------------------------------------------------------------------------
# Chargement du modèle + des papillons vedettes (une seule fois au démarrage)
# --------------------------------------------------------------------------
processor = AutoImageProcessor.from_pretrained(MODEL_CKPT)
model = AutoModel.from_pretrained(MODEL_CKPT).to(device).eval()
def embed(image: Image.Image) -> np.ndarray:
"""Calcule l'empreinte (embedding L2-normalisée) d'une image."""
inputs = processor(images=image.convert("RGB"), return_tensors="pt").to(device)
with torch.no_grad():
out = model(**inputs)
vec = out.pooler_output[0].cpu().numpy().astype("float32")
norm = np.linalg.norm(vec)
return vec / norm if norm > 0 else vec
def _norm(s: str) -> str:
"""Normalise un nom : minuscules, sans espaces/underscores/ponctuation."""
return "".join(ch for ch in s.lower() if ch.isalnum())
def resolve_frame(latin: str):
"""Trouve le cadre d'une espèce, peu importe le format du nom de fichier.
Tolère espaces, underscores, casse et concaténation
(ex. 'Troides rhadamantus' -> 'troidesrhadamantus.png').
"""
target = _norm(latin)
for fn in os.listdir(FRAMES_DIR):
stem, ext = os.path.splitext(fn)
if ext.lower() == ".png" and _norm(stem) == target:
return os.path.join(FRAMES_DIR, fn)
return None
def load_butterflies():
"""Lit le CSV et calcule l'empreinte de la photo de référence de chaque espèce."""
names, frames, embeddings = [], [], []
with open(CSV_PATH, encoding="utf-8") as f:
rows = list(csv.reader(f))
for row in rows[1:]: # saute l'en-tête
if not row or not row[0].strip():
continue
latin, fr_name, canonical_img = row[0].strip(), row[1].strip(), row[3].strip()
photo_path = os.path.join(PHOTOS_DIR, latin, canonical_img)
frame_path = resolve_frame(latin)
if not (os.path.exists(photo_path) and frame_path):
print(f"⚠️ Fichier manquant pour {latin} (photo ou cadre) — espèce ignorée.")
continue
embeddings.append(embed(Image.open(photo_path)))
names.append(fr_name)
frames.append(frame_path)
print(f"✅ {len(names)} papillons vedettes chargés.")
return names, frames, np.stack(embeddings)
NAMES, FRAMES, EMB = load_butterflies()
# --------------------------------------------------------------------------
# Composition du selfie dans le cadre
# --------------------------------------------------------------------------
def crop_to_aspect(img: Image.Image, target_w: int, target_h: int) -> Image.Image:
"""Recadre l'image au centre selon le ratio voulu, puis redimensionne."""
w, h = img.size
target_ratio = target_w / target_h
if w / h > target_ratio:
new_w = int(h * target_ratio)
left = (w - new_w) // 2
img = img.crop((left, 0, left + new_w, h))
else:
new_h = int(w / target_ratio)
top = (h - new_h) // 2
img = img.crop((0, top, w, top + new_h))
return img.resize((target_w, target_h), Image.LANCZOS)
def compose(selfie: Image.Image, frame_path: str) -> Image.Image:
"""Place le selfie derrière le cadre : il apparaît dans le cercle transparent."""
frame = Image.open(frame_path).convert("RGBA")
win_w, win_h = WINDOW[2] - WINDOW[0], WINDOW[3] - WINDOW[1]
selfie = crop_to_aspect(selfie.convert("RGBA"), win_w, win_h)
canvas = Image.new("RGBA", frame.size, (255, 255, 255, 255))
canvas.paste(selfie, (WINDOW[0], WINDOW[1]))
canvas.alpha_composite(frame)
return canvas.convert("RGB")
# --------------------------------------------------------------------------
# Logique de matching
# --------------------------------------------------------------------------
def match(selfie: Image.Image) -> Image.Image:
"""Trouve le papillon le plus proche et renvoie la composition finale."""
query = embed(selfie)
sims = EMB @ query # similarité cosinus (vecteurs normalisés)
best = int(np.argmax(sims))
return compose(selfie, FRAMES[best])
# --------------------------------------------------------------------------
# Parcours photobooth (3 étapes)
# --------------------------------------------------------------------------
COUNTDOWN_SECONDS = 5
def show_camera():
"""Étape 2 (serveur) : cache l'accueil et un éventuel résultat précédent.
L'ouverture réelle de la caméra et le décompte se font côté navigateur
(voir CAPTURE_JS). Sorties : [start_btn, intro_md, result,
download_btn, retake_btn].
"""
return (
gr.update(visible=False), # start_btn
gr.update(visible=False), # intro_md
gr.update(visible=False), # result
gr.update(visible=False), # download_btn
gr.update(visible=False), # retake_btn
)
def process_photo(data_url):
"""Étape 3 (serveur) : décode le selfie capturé, compose, affiche le résultat.
Sorties : [start_btn, intro_md, result, download_btn, retake_btn].
"""
if not data_url:
return (gr.skip(),) * 5
if data_url == "ERROR":
# Caméra refusée / indisponible : on revient à l'accueil.
return (
gr.update(visible=True), # start_btn
gr.update(visible=True), # intro_md
gr.update(visible=False), # result
gr.update(visible=False), # download_btn
gr.update(visible=False), # retake_btn
)
# data_url = "data:image/jpeg;base64,...."
b64 = data_url.split(",", 1)[1]
selfie = Image.open(io.BytesIO(base64.b64decode(b64))).convert("RGB")
result = match(selfie)
# Sauvegarde dans un dossier temporaire unique (nom propre pour le téléchargement).
out_dir = tempfile.mkdtemp()
path = os.path.join(out_dir, "mon_papillon.jpg")
result.save(path, quality=92)
return (
gr.update(visible=False), # start_btn
gr.update(visible=False), # intro_md
gr.update(value=result, visible=True), # result
gr.update(value=path, visible=True), # download_btn
gr.update(visible=True), # retake_btn
)
# JS exécuté dans le navigateur : construit un overlay plein écran avec l'aperçu
# caméra et le décompte, demande la caméra, capture l'image à la fin du décompte.
# Retourne l'image en base64 (ou "ERROR" si la caméra est refusée/indisponible).
CAPTURE_JS = f"""
async () => {{
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed; inset:0; z-index:9999;'
+ 'background:rgba(0,0,0,0.92); display:flex;'
+ 'align-items:center; justify-content:center;';
const video = document.createElement('video');
video.autoplay = true; video.muted = true;
video.setAttribute('playsinline', '');
video.style.cssText = 'max-width:92vw; max-height:80vh; border-radius:12px;';
const count = document.createElement('div');
count.style.cssText = 'position:absolute; font-size:22vw; font-weight:800;'
+ 'color:#fff; text-shadow:0 2px 24px rgba(0,0,0,0.8);';
overlay.appendChild(video);
overlay.appendChild(count);
document.body.appendChild(overlay);
let stream;
try {{
stream = await navigator.mediaDevices.getUserMedia(
{{ video: {{ facingMode: 'user' }}, audio: false }}
);
}} catch (e) {{
overlay.remove();
return 'ERROR';
}}
video.srcObject = stream;
try {{ await video.play(); }} catch (e) {{}}
// Décompte (ne démarre qu'une fois la caméra autorisée et active).
for (let i = {COUNTDOWN_SECONDS}; i >= 1; i--) {{
count.textContent = i;
await new Promise(r => setTimeout(r, 1000));
}}
count.textContent = '📸';
await new Promise(r => setTimeout(r, 250));
// Capture l'image courante.
const w = video.videoWidth, h = video.videoHeight;
const canvas = document.createElement('canvas');
canvas.width = w; canvas.height = h;
canvas.getContext('2d').drawImage(video, 0, 0, w, h);
const dataUrl = canvas.toDataURL('image/jpeg', 0.92);
stream.getTracks().forEach(t => t.stop());
overlay.remove();
return dataUrl;
}}
"""
# --------------------------------------------------------------------------
# Interface
# --------------------------------------------------------------------------
PANTONE_7417C = "#E1523D"
CSS = f"""
#title, #subtitle, #intro {{ text-align: center; }}
#title h1 {{ margin-bottom: 0; }}
#result {{ max-width: 540px; margin: 0 auto; }}
/* Le français (1er paragraphe) plus grand que l'anglais (<small>) */
#intro p {{ margin: 0.2em 0; }}
#intro p:first-child {{ font-size: 1.2rem; line-height: 1.4; }}
#intro small {{ font-size: 0.85rem; opacity: 0.75; }}
.pantone-btn, .pantone-btn button, button.pantone-btn {{
background: {PANTONE_7417C} !important;
border-color: {PANTONE_7417C} !important;
color: #ffffff !important;
}}
.pantone-btn:hover, .pantone-btn button:hover, button.pantone-btn:hover {{
filter: brightness(0.92);
}}
/* Boutons bilingues : libellé FR (taille du bouton) + EN plus petit en dessous */
.bibtn::after {{
display: block;
font-size: 0.7em;
font-weight: 400;
font-style: normal;
opacity: 0.85;
margin-top: 2px;
}}
.bi-find::after {{ content: "Find my butterfly"; }}
.bi-dl::after {{ content: "Download my photo"; }}
.bi-rt::after {{ content: "Take another photo"; }}
"""
with gr.Blocks(title="Trouve mon papillon 🦋", theme=gr.themes.Soft(), css=CSS) as demo:
# Toujours visibles
gr.Markdown(
"# Quel papillon es-tu ? 🦋\n"
"#### What butterfly are you?",
elem_id="title",
)
gr.Markdown(
"### Lepidoptera — Éloge aux papillons\n"
"##### *A tribute to butterflies* · Insectarium, Espace pour la vie",
elem_id="subtitle",
)
# Visible seulement à l'accueil (étape 1) — FR (grand) puis EN (<small>)
intro_md = gr.Markdown(
"Chaque papillon arbore des couleurs uniques qui le rendent reconnaissable. "
"Montre tes propres couleurs pour découvrir à quel papillon tu ressembles le plus !\n\n"
"<small>Butterflies and moths show off unique colors that makes each of them "
"recognizable. Show off your own colors and find out which butterfly you are!</small>",
elem_id="intro",
)
start_btn = gr.Button(
"Trouve mon papillon ! 🦋",
variant="primary",
size="lg",
elem_classes=["pantone-btn", "bibtn", "bi-find"],
)
# Étape 2 : aperçu caméra + décompte gérés entièrement en JS (overlay plein écran)
photo_data = gr.Textbox(visible=False) # reçoit le selfie capturé (base64)
# Étape 3 : résultat + téléchargement + reprise (cachés au départ)
result = gr.Image(
show_label=False,
show_download_button=False,
interactive=False,
format="jpeg",
visible=False,
elem_id="result",
)
download_btn = gr.DownloadButton(
"📥 Télécharger ma photo",
variant="primary",
visible=False,
elem_classes=["pantone-btn", "bibtn", "bi-dl"],
)
retake_btn = gr.Button(
"↻ Reprendre une autre photo",
variant="secondary",
size="sm",
visible=False,
elem_classes=["bibtn", "bi-rt"],
)
step_outputs = [start_btn, intro_md, result, download_btn, retake_btn]
# Clic accueil / reprise : on masque l'UI (serveur) puis on lance la caméra (JS).
start_btn.click(show_camera, outputs=step_outputs).then(
None, inputs=None, outputs=photo_data, js=CAPTURE_JS
)
retake_btn.click(show_camera, outputs=step_outputs).then(
None, inputs=None, outputs=photo_data, js=CAPTURE_JS
)
# Quand le selfie capturé arrive, on compose et on affiche le résultat.
photo_data.change(process_photo, inputs=photo_data, outputs=step_outputs)
if __name__ == "__main__":
demo.queue(default_concurrency_limit=4)
demo.launch()