Spaces:
Sleeping
Sleeping
| import streamlit as st | |
| import mne | |
| import tempfile | |
| import os | |
| import matplotlib.pyplot as plt | |
| import plotly.graph_objs as go | |
| import numpy as np | |
| from datetime import datetime | |
| from fpdf import FPDF | |
| import pathlib | |
| import requests | |
| from scipy import signal | |
| import google.generativeai as genai | |
| import pandas as pd | |
| from gemini_report import generate_full_llm_report | |
| # Configuration de la page | |
| st.set_page_config(page_title="🧠 Application de Détection de l’Épilepsie par EEG", layout="wide") | |
| # ✅ Fond d'écran personnalisé | |
| BACKGROUND_URL = "https://img.freepik.com/vector-gratis/fondo-medico-sanitario-espacio-texto_1017-26841.jpg" | |
| def set_background_from_url(url): | |
| css = f""" | |
| <style> | |
| html, body {{ | |
| height: 100%; | |
| margin: 0; | |
| padding: 0; | |
| background-image: url('{url}'); | |
| background-size: cover; | |
| background-position: center; | |
| background-repeat: no-repeat; | |
| background-attachment: fixed; | |
| }} | |
| .stApp {{ | |
| background-color: rgba(255, 255, 255, 0.85); | |
| }} | |
| </style> | |
| """ | |
| st.markdown(css, unsafe_allow_html=True) | |
| set_background_from_url(BACKGROUND_URL) | |
| # ============================== | |
| # Interface à onglets | |
| # ============================== | |
| tabs = st.tabs([ | |
| "🏠 Accueil", | |
| "📈 Visualisation EEG", | |
| "🔎 Prédiction", | |
| "📋 Résultat Clinique", | |
| "📝 Rapport LLM (Gemini)", | |
| "❓ À propos" | |
| ]) | |
| # Variables de session | |
| if "raw" not in st.session_state: | |
| st.session_state.raw = None | |
| if "filename" not in st.session_state: | |
| st.session_state.filename = None | |
| if "tmp_path" not in st.session_state: | |
| st.session_state.tmp_path = None | |
| if "prediction_result" not in st.session_state: | |
| st.session_state.prediction_result = None | |
| # ============================== | |
| # Onglet Accueil | |
| # ============================== | |
| with tabs[0]: | |
| st.title("🧠 Application de Détection de l’Épilepsie par EEG") | |
| st.markdown(""" | |
| ### 🏥 Outil d’Aide à la Décision Clinique | |
| Basé sur l’analyse automatique des signaux EEG | |
| ### 🧬 Description / Contexte médical | |
| Cette application est un système d’aide à la décision clinique conçu pour assister les professionnels de santé dans l’interprétation d’enregistrements EEG dans le cadre du dépistage et du diagnostic de l’épilepsie. | |
| Elle repose sur des algorithmes d’apprentissage automatique entraînés sur des milliers de segments EEG annotés (Temple University Hospital), capables de prédire la probabilité qu’un enregistrement soit associé à une activité épileptiforme. | |
| ⚠️ Ce système n’est pas un dispositif médical certifié. Son utilisation est réservée aux professionnels de santé, comme outil complémentaire à l’analyse clinique. | |
| """) | |
| # ============================== | |
| # Onglet Visualisation EEG | |
| # ============================== | |
| with tabs[1]: | |
| st.header("📈 Visualisation des signaux EEG") | |
| uploaded_file = st.file_uploader("📄 Charger un fichier .edf", type=["edf"]) | |
| if uploaded_file is not None: | |
| with tempfile.NamedTemporaryFile(delete=False, suffix=".edf") as tmp: | |
| tmp.write(uploaded_file.read()) | |
| tmp_path = tmp.name | |
| try: | |
| raw = mne.io.read_raw_edf(tmp_path, preload=True) | |
| st.session_state.raw = raw | |
| st.session_state.filename = uploaded_file.name | |
| st.session_state.tmp_path = tmp_path | |
| st.success("✅ Fichier EDF chargé avec succès") | |
| except Exception as e: | |
| st.error(f"❌ Erreur : {e}") | |
| raw = st.session_state.raw | |
| if raw is not None: | |
| st.write(f"**Fichier :** {st.session_state.filename}") | |
| st.write(f"**Nombre de canaux :** {len(raw.ch_names)}") | |
| st.write(f"**Durée :** {raw.times[-1] / 60:.2f} minutes") | |
| viz_type = st.selectbox("🎨 Choisir le type de visualisation", | |
| ["Multi-canaux (Plotly)", "Canal unique", "Spectrogramme"]) | |
| total_duration = raw.times[-1] | |
| window_duration = 30 | |
| start_time = st.slider("⏱️ Position de départ (sec)", 0.0, float(max(0.0, total_duration - window_duration)), 0.0, 1.0) | |
| if viz_type == "Multi-canaux (Plotly)": | |
| selected_channels = st.multiselect("Sélectionner les canaux (max 5)", raw.ch_names, default=raw.ch_names[:5]) | |
| if len(selected_channels) > 5: | |
| st.warning("⚠️ Veuillez sélectionner 5 canaux maximum.") | |
| elif selected_channels: | |
| try: | |
| raw_segment = raw.copy().pick_channels(selected_channels).crop(tmin=start_time, tmax=start_time + window_duration) | |
| data, times = raw_segment.get_data(return_times=True) | |
| traces = [] | |
| for i, channel in enumerate(selected_channels): | |
| traces.append(go.Scatter(x=times, y=data[i], mode="lines", name=channel)) | |
| fig = go.Figure(data=traces) | |
| fig.update_layout(title="EEG multi-canaux", xaxis_title="Temps (s)", yaxis_title="Amplitude (µV)") | |
| st.plotly_chart(fig) | |
| except Exception as e: | |
| st.error(f"Erreur affichage multi-canaux : {e}") | |
| elif viz_type == "Canal unique": | |
| selected_channel = st.selectbox("Choisir un canal EEG", raw.ch_names) | |
| try: | |
| raw_segment = raw.copy().pick_channels([selected_channel]).crop(tmin=start_time, tmax=start_time + window_duration) | |
| data, times = raw_segment.get_data(return_times=True) | |
| trace = go.Scatter(x=times, y=data[0], mode="lines", name=selected_channel) | |
| fig = go.Figure(data=[trace]) | |
| fig.update_layout(title=f"EEG - {selected_channel}", xaxis_title="Temps (s)", yaxis_title="Amplitude (µV)") | |
| st.plotly_chart(fig) | |
| except Exception as e: | |
| st.error(f"Erreur affichage canal unique : {e}") | |
| elif viz_type == "Spectrogramme": | |
| selected_channel = st.selectbox("Choisir un canal pour le spectrogramme", raw.ch_names) | |
| try: | |
| raw_segment = raw.copy().pick_channels([selected_channel]).crop(tmin=start_time, tmax=start_time + window_duration) | |
| data, times = raw_segment.get_data(return_times=True) | |
| fs = int(raw.info['sfreq']) | |
| f, t, Sxx = signal.spectrogram(data[0], fs) | |
| fig, ax = plt.subplots(figsize=(7, 3.5)) | |
| im = ax.pcolormesh(t, f, 10 * np.log10(Sxx), shading='gouraud') | |
| ax.set_ylabel('Fréquence [Hz]') | |
| ax.set_xlabel('Temps [s]') | |
| ax.set_title(f"Spectrogramme : {selected_channel}") | |
| plt.colorbar(im, ax=ax, label='dB') | |
| st.pyplot(fig) | |
| st.markdown(""" | |
| --- | |
| ### 🧠 Légende - Signification clinique des bandes de fréquence EEG | |
| | Bande (Hz) | Nom | État physiologique typique | Indication clinique | | |
| |------------|----------|-----------------------------|----------------------| | |
| | 0.5–4 | Delta | Sommeil profond | Présente éveillé = suspicion de pathologie | | |
| | 4–8 | Theta | Somnolence, relaxation | Exagéré dans certaines pathologies | | |
| | 8–13 | Alpha | Éveil calme (yeux fermés) | Disparaît yeux ouverts | | |
| | 13–30 | Beta | Concentration, éveil actif | Accentué sous stress ou médication | | |
| | >30 | Gamma | Activité cognitive | Rarement analysée en clinique | | |
| ℹ️ Le spectrogramme permet de visualiser les variations fréquentielles du signal EEG dans le temps. Il est particulièrement utile pour détecter des décharges anormales ou des crises. | |
| """) | |
| except Exception as e: | |
| st.error(f"Erreur affichage spectrogramme : {e}") | |
| st.markdown(""" | |
| #### ℹ️ Remarque | |
| Ces métriques ont été obtenues à partir de segments EEG de patients annotés manuellement (crises, artefacts, normalité). | |
| Le modèle est optimisé pour une agrégation des segments à l’échelle patient, permettant une prédiction robuste au niveau global. | |
| """) | |
| else: | |
| st.info("Veuillez charger un fichier EDF ci-dessus.") | |
| # ============================== | |
| # Onglet Prédiction | |
| # ============================== | |
| with tabs[2]: | |
| st.header("🔎 Prédiction via API externe") | |
| raw = st.session_state.raw | |
| tmp_path = st.session_state.tmp_path | |
| if raw is not None and tmp_path is not None: | |
| # ✅ Mapping affichage utilisateur → valeur envoyée à l'API | |
| model_mapping = { | |
| "🧠 Modèle 1 (CNN 2D)": "2DCNN", | |
| "⚡ Modèle 2 (EEGNet)": "EEGNet", | |
| "🌀 Modèle 3 (EpilepsyNet)": "EpilepsyNet", | |
| } | |
| model_display = st.selectbox("🧠 Choisir le modèle de prédiction", list(model_mapping.keys())) | |
| selected_model = model_mapping[model_display] | |
| if st.button("📡 Lancer la prédiction"): | |
| try: | |
| api_url = "https://MorganBrizon-EEG-API.hf.space/predict" | |
| # Préparation du fichier | |
| with open(tmp_path, "rb") as f: | |
| files = {"file": (st.session_state.filename, f, "application/octet-stream")} | |
| # ✅ Préparation des bons paramètres | |
| if selected_model == "ensemble_avg": | |
| params = {"model_choice": "ensemble", "ensemble_method": "average"} | |
| elif selected_model == "ensemble_vote": | |
| params = {"model_choice": "ensemble", "ensemble_method": "voting"} | |
| else: | |
| params = {"model_choice": selected_model} | |
| # Envoi vers l’API | |
| response = requests.post(api_url, files=files, params=params) | |
| if response.status_code != 200: | |
| st.error(f"❌ Erreur API : {response.status_code} - {response.text}") | |
| st.stop() | |
| result = response.json() | |
| label = result.get("prediction") | |
| prob = result.get("mean_probability") | |
| segment_probs = result.get("segment_probabilities", []) | |
| if label == "no epilepsy": prob = 1-prob | |
| # Enregistrement en session | |
| st.session_state.prediction_result = (label, prob) | |
| st.session_state.segment_probs = segment_probs | |
| st.success("✅ Prédiction reçue") | |
| st.write(f"🧠 Résultat global : **{label}**") | |
| st.write(f"📊 Probabilité moyenne : **{prob * 100:.2f}%**") | |
| if prob < 0.6: | |
| st.warning("🔍 Confiance : Faible") | |
| elif prob < 0.85: | |
| st.info("✅ Confiance : Moyenne") | |
| else: | |
| st.success("💪 Confiance : Élevée") | |
| # Tableau et graphe | |
| if segment_probs: | |
| st.markdown("### 📋 Prédictions par segment EEG") | |
| import pandas as pd | |
| import matplotlib.pyplot as plt | |
| segment_length = 5 | |
| overlap = 2 | |
| step = segment_length - overlap | |
| df = pd.DataFrame({ | |
| "Segment (sec)": [f"{i * step}-{i * step + segment_length}s" for i in range(len(segment_probs))], | |
| "Probabilité (%)": [round(p * 100, 2) for p in segment_probs], | |
| "Étiquette": ["épilepsie" if p > 0.5 else "non" for p in segment_probs] | |
| }) | |
| st.dataframe(df, use_container_width=True) | |
| total_seconds = (len(segment_probs) - 1) * step + segment_length | |
| minutes = total_seconds // 60 | |
| seconds = total_seconds % 60 | |
| st.write(f"🧮 Nombre de segments : **{len(segment_probs)}**") | |
| st.write(f"⏱️ Durée EEG analysée : **{minutes} min {seconds} sec**") | |
| st.markdown("### 📊 Visualisation graphique des segments") | |
| fig, ax = plt.subplots(figsize=(12, 3)) | |
| colors = ['red' if p > 0.5 else 'green' for p in segment_probs] | |
| ax.bar(range(len(segment_probs)), segment_probs, color=colors) | |
| ax.set_xticks(range(0, len(segment_probs), max(1, len(segment_probs) // 20))) | |
| ax.set_xticklabels( | |
| [f"{i * step}-{i * step + segment_length}s" for i in range(0, len(segment_probs), max(1, len(segment_probs) // 20))], | |
| rotation=45 | |
| ) | |
| ax.set_ylim(0, 1) | |
| ax.set_ylabel("Probabilité") | |
| ax.set_title("Probabilité d’épilepsie par segment (fenêtres de 5s, pas de 3s)") | |
| st.pyplot(fig) | |
| st.caption("🔴 = épilepsie (p > 0.5) · 🟢 = non épileptique") | |
| else: | |
| st.warning("⚠️ Aucune prédiction segmentée reçue.") | |
| except Exception as e: | |
| st.error(f"Erreur API : {e}") | |
| else: | |
| st.info("Veuillez charger un fichier EDF dans l'onglet '📈 Visualisation EEG'.") | |
| # ============================== | |
| # Onglet Résultat Clinique | |
| # ============================== | |
| with tabs[3]: | |
| st.header("📋 Rapport Clinique") | |
| if st.session_state.prediction_result and st.session_state.raw: | |
| label, prob = st.session_state.prediction_result | |
| now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") | |
| st.write(f"**Identifiant patient :** {st.session_state.filename}") | |
| st.write(f"**Date et heure :** {now}") | |
| st.write(f"**Résultat prédiction :** {label}") | |
| if prob is not None: | |
| st.write(f"**Probabilité :** {prob * 100:.2f}%") | |
| st.markdown("### 🧪 Interprétation") | |
| if "non" in label.lower(): | |
| st.info("Le modèle ne détecte pas de signes épileptiques caractéristiques.") | |
| texte_interpretation = "Le modele ne detecte pas de signes epileptiques caracteristiques." | |
| else: | |
| st.warning("Le modèle détecte des signes compatibles avec une activité épileptique.") | |
| texte_interpretation = "Le modele detecte des signes compatibles avec une activite epileptique." | |
| st.markdown("### ⚠️ Mentions légales") | |
| st.caption(""" | |
| Cet outil d’aide à la décision clinique ne constitue pas un diagnostic médical. | |
| Il ne doit être utilisé qu’en complément de l’évaluation médicale complète. | |
| """) | |
| # 📥 Génération du rapport PDF | |
| pdf = FPDF() | |
| pdf.add_page() | |
| pdf.set_auto_page_break(auto=True, margin=15) | |
| font_path = str(pathlib.Path(__file__).parent / "DejaVuSans.ttf") | |
| try: | |
| pdf.add_font("DejaVu", "", font_path, uni=True) | |
| except RuntimeError: | |
| st.error("❌ Impossible de charger la police DejaVuSans.ttf. Place-la dans le même dossier que app.py.") | |
| st.stop() | |
| pdf.set_font("DejaVu", size=12) | |
| pdf.cell(200, 10, txt="Rapport d'analyse EEG", ln=True, align="C") | |
| pdf.ln(10) | |
| pdf.cell(200, 10, txt=f"Identifiant patient : {st.session_state.filename}", ln=True) | |
| pdf.cell(200, 10, txt=f"Date et heure de l’analyse : {now}", ln=True) | |
| pdf.cell(200, 10, txt=f"Résultat de la prédiction : {label}", ln=True) | |
| if prob is not None: | |
| pdf.cell(200, 10, txt=f"Probabilité : {prob * 100:.2f}%", ln=True) | |
| pdf.ln(10) | |
| pdf.multi_cell(0, 10, txt="Interprétation :") | |
| texte_interpretation = texte_interpretation.encode('ascii', 'ignore').decode() | |
| pdf.multi_cell(w=190, h=10, txt=texte_interpretation) | |
| pdf.ln(10) | |
| pdf.multi_cell(0, 10, txt="Mentions légales :") | |
| pdf.set_font("DejaVu", size=10) | |
| texte_legal = "Cet outil d'aide a la decision clinique ne constitue pas un diagnostic medical. Il ne doit etre utilise qu'en complement de l'evaluation medicale complete." | |
| texte_legal = texte_legal.encode('ascii', 'ignore').decode() | |
| pdf.multi_cell(w=190, h=10, txt=texte_legal) | |
| pdf.ln(5) | |
| pdf.set_font("DejaVu", size=10) | |
| remarque = "Ces metriques ont ete obtenues a partir de segments EEG de patients annotes manuellement (crises, artefacts, normalite). Le modele est optimise pour une aggregation des segments a l'echelle patient, permettant une prediction robuste au niveau global." | |
| remarque = remarque.encode('ascii', 'ignore').decode() | |
| pdf.multi_cell(w=190, h=10, txt=remarque) | |
| pdf_output_path = os.path.join(tempfile.gettempdir(), "rapport_eeg.pdf") | |
| pdf.output(pdf_output_path) | |
| with open(pdf_output_path, "rb") as f: | |
| st.download_button(label="📥 Télécharger le rapport PDF", data=f, file_name="rapport_eeg.pdf") | |
| else: | |
| st.info("Lancez une prédiction dans l'onglet 'Prédiction'.") | |
| # ============================== | |
| # Onglet Rapport par LLM (Gemini) | |
| # ============================== | |
| if "GEMINI_API_KEY" in st.secrets: | |
| genai.configure(api_key=st.secrets["GEMINI_API_KEY"]) | |
| else: | |
| st.warning("⚠️ Clé Gemini manquante dans secrets.toml") | |
| from gemini_report import generate_full_llm_report | |
| with tabs[4]: | |
| st.header("🖍️ Rapport Clinique via LLM (Gemini)") | |
| raw = st.session_state.get("raw", None) | |
| tmp_path = st.session_state.get("tmp_path", None) | |
| filename = st.session_state.get("filename", None) | |
| if raw and tmp_path and filename: | |
| if st.button("🧠 Générer le rapport avec Gemini"): | |
| with st.spinner("Génération du rapport en cours..."): | |
| # 👤 Métadonnées fictives (non affichées dans l'interface) | |
| metadata = { | |
| "subject_id": "Unknown", | |
| "age": "Unknown", | |
| "gender": "Unknown", | |
| "epilepsy": "Unknown" | |
| } | |
| try: | |
| report = generate_full_llm_report(tmp_path, metadata) | |
| st.markdown("### 💾 Rapport EEG généré :") | |
| st.text_area("🧾 Rapport médical structuré", report, height=500) | |
| except Exception as e: | |
| st.error(f"❌ Erreur pendant la génération du rapport : {e}") | |
| else: | |
| st.info("📂 Veuillez charger un fichier EDF dans l’onglet '📈 Visualisation EEG'.") | |
| # ============================== | |
| # Onglet À propos | |
| # ============================== | |
| with tabs[5]: | |
| st.title("❓ À propos de cet outil") | |
| st.markdown(""" | |
| 🧠 **À propos de cet outil :** | |
| Cet outil repose sur des techniques d’apprentissage profond (Deep Learning) pour détecter automatiquement des patterns épileptiformes dans des enregistrements EEG cliniques. | |
| Le modèle a été entraîné et validé sur des enregistrements annotés du TUH EEG corpus (Temple University Epilepsy Corpus), largement utilisé dans la recherche sur l’épilepsie. | |
| 🔬 **Méthodologie :** | |
| - **Prétraitement :** | |
| - Sélection de 5 canaux EEG standardisés (EEG FP1-REF, EEG FP2-REF, EEG F3-REF, EEG F4-REF, EEG C3-REF) | |
| - Filtrage passe-bande (1–45 Hz) | |
| - Rééchantillonnage à 250 Hz | |
| - Normalisation canal par canal | |
| - Segmentation des enregistrements en fenêtres de 5 secondes | |
| - **Architecture du modèle :** | |
| - 2D-CNN entraîné à partir de segments EEG transformés en matrices 2D (canal × temps) | |
| - Couches convolutives extrayant des motifs spatio-temporels liés à l’activité épileptiforme | |
| - Couches fully connected pour la classification binaire (épileptique / non-épileptique) | |
| - Fonction de perte : Binary Cross-Entropy | |
| - Optimisation : Adam | |
| - **Évaluation & sortie :** | |
| - Prédictions réalisées au niveau des segments EEG | |
| - Agrégation des prédictions pour produire un score probabiliste patient-level | |
| - Retour d’une probabilité d’épilepsie (%), accompagnée d’une interprétation localisée | |
| ⚠️ **Limites de l’outil :** | |
| - Moins fiable sur : | |
| - Signaux de faible qualité ou fortement bruités | |
| - Formes rares, focales profondes ou non typiques d’épilepsie | |
| - Ne remplace pas l’expertise médicale humaine (neurologue) | |
| """) | |