Spaces:
Sleeping
Sleeping
Fix GT-map panel title incorrectly showing 'Votre modele KNN' on the logistic regression and random forest tabs
1863095 verified | import gradio as gr | |
| import numpy as np | |
| import matplotlib | |
| matplotlib.use('Agg') | |
| import matplotlib.pyplot as plt | |
| from matplotlib.patches import Patch | |
| import rasterio | |
| import json | |
| import os | |
| from sklearn.neighbors import KNeighborsClassifier | |
| from sklearn.linear_model import LogisticRegression | |
| from sklearn.ensemble import RandomForestClassifier | |
| from sklearn.preprocessing import StandardScaler | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # Constants | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| N_POLYGONS = 23 | |
| K_MIN, K_MAX, K_DEFAULT = 1, 15, 5 | |
| CLASSES = { | |
| 1: "Eau", | |
| 2: "Vergers", | |
| 3: "Cultures dans le Delta", | |
| 4: "Zones bâties", | |
| 5: "Cultures irriguées dans le désert", | |
| 6: "Cultures non irriguées en zone sèche", | |
| 7: "Zones sableuses", | |
| } | |
| CLASS_CHOICES = [f"{k} - {v}" for k, v in CLASSES.items()] | |
| # index 0 = fond / non classé, 1..7 = classes ci-dessus | |
| COLORS_RGB = np.array([ | |
| [20, 20, 20], [0, 100, 220], [0, 160, 60], [120, 220, 100], | |
| [220, 50, 50], [255, 165, 0], [160, 90, 30], [240, 230, 140], | |
| ], dtype=np.uint8) | |
| BAND_FILES = [ | |
| 'band_1_uv.tif', 'band_2_blue.tif', 'band_3_green.tif', 'band_4_red.tif', | |
| 'band_5_nir.tif', 'band_6_swir1.tif', 'band_7_swir2.tif', | |
| ] | |
| # ground_truth.tif and knn_result.tif were exported (QGIS) with a class-code order | |
| # that does NOT match CLASSES above. Confirmed by cross-referencing the professor's | |
| # Excel answer key (Copie de TD_Vallee du Nil_Complet.xlsx, sheet "Training (RSL)") | |
| # against the raw codes at the 23 training-polygon locations (23/23 match this single | |
| # permutation), and independently via NIR/SWIR spectral signature per code. Index i of | |
| # this LUT is the raw code found in the tif, value is the true CLASSES code. | |
| CLASS_REMAP_LUT = np.array([0, 1, 4, 7, 5, 6, 2, 3], dtype=np.uint8) | |
| BASE_DIR = os.path.dirname(os.path.abspath(__file__)) | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # Data loading (once at startup) | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| def load_data(): | |
| def read_tif(name): | |
| path = os.path.join(BASE_DIR, 'data', name) | |
| with rasterio.open(path) as src: | |
| return src.read(1) | |
| ground_truth = CLASS_REMAP_LUT[read_tif('ground_truth.tif')] | |
| knn_result = CLASS_REMAP_LUT[read_tif('knn_result.tif')] | |
| training_ids = read_tif('training_polygons.tif') | |
| bands = np.stack( | |
| [read_tif(os.path.join('bands', f)) for f in BAND_FILES], axis=-1 | |
| ).astype(np.float32) | |
| with open(os.path.join(BASE_DIR, 'data', 'polygon_teacher_classes.json')) as f: | |
| polygon_teacher = {int(k): v for k, v in json.load(f).items()} | |
| # Pre-compute reference KNN confusion matrix (teacher labels, pixels where GT > 0) | |
| gt_flat = ground_truth.flatten() | |
| knn_flat = knn_result.flatten() | |
| valid = gt_flat > 0 | |
| knn_matrix = np.zeros((7, 7), dtype=np.int64) | |
| np.add.at(knn_matrix, (gt_flat[valid] - 1, knn_flat[valid] - 1), 1) | |
| knn_oa = knn_matrix.diagonal().sum() / knn_matrix.sum() | |
| # Row/col coordinates of the Ground Truth pixels, for point-based map plots | |
| gt_rows, gt_cols = np.where(ground_truth > 0) | |
| return dict( | |
| polygon_teacher= polygon_teacher, | |
| ground_truth = ground_truth, | |
| knn_result = knn_result, | |
| training_ids = training_ids, | |
| bands = bands, | |
| knn_matrix = knn_matrix, | |
| knn_oa = knn_oa, | |
| gt_rows = gt_rows, | |
| gt_cols = gt_cols, | |
| ) | |
| DATA = load_data() | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # Model training (from the student's own labels + real spectral bands) | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| def train_and_predict(student_labels, k): | |
| """Train a KNN classifier on the student's 23 labeled polygons (using the | |
| real spectral bands), then evaluate it only at the Ground Truth pixel | |
| locations (the only place where we actually know the right answer).""" | |
| bands = DATA['bands'] | |
| training_ids = DATA['training_ids'] | |
| ground_truth = DATA['ground_truth'] | |
| knn_result = DATA['knn_result'] | |
| train_mask = training_ids > 0 | |
| X_train = bands[train_mask] | |
| y_train = np.array([int(student_labels[pid - 1]) for pid in training_ids[train_mask]]) | |
| scaler = StandardScaler().fit(X_train) | |
| clf = KNeighborsClassifier(n_neighbors=int(k), algorithm='kd_tree', n_jobs=-1) | |
| clf.fit(scaler.transform(X_train), y_train) | |
| # Predict only at the Ground Truth pixels (real accuracy + a clean map) | |
| gt_mask = ground_truth > 0 | |
| X_gt = bands[gt_mask] | |
| pred_gt = clf.predict(scaler.transform(X_gt)) | |
| y_gt = ground_truth[gt_mask] | |
| knn_ref_gt = knn_result[gt_mask] | |
| model_matrix = np.zeros((7, 7), dtype=np.int64) | |
| np.add.at(model_matrix, (y_gt - 1, pred_gt - 1), 1) | |
| model_oa = model_matrix.diagonal().sum() / model_matrix.sum() | |
| # Full-image prediction: the model classifies EVERY pixel, not just the ones | |
| # with a known Ground Truth. We can't evaluate accuracy outside GT locations | |
| # (no known answer there) but we can still show what the model predicted. | |
| H, W, n_bands = bands.shape | |
| pred_full = clf.predict(scaler.transform(bands.reshape(-1, n_bands))).reshape(H, W) | |
| return model_oa, model_matrix, pred_gt, y_gt, knn_ref_gt, pred_full | |
| def train_and_predict_logreg(student_labels): | |
| """Train a multinomial logistic regression on the student's 23 labeled | |
| polygons (using the real spectral bands), then evaluate it only at the | |
| Ground Truth pixel locations — same protocol as the KNN model, so the two | |
| are directly comparable.""" | |
| bands = DATA['bands'] | |
| training_ids = DATA['training_ids'] | |
| ground_truth = DATA['ground_truth'] | |
| knn_result = DATA['knn_result'] | |
| train_mask = training_ids > 0 | |
| X_train = bands[train_mask] | |
| y_train = np.array([int(student_labels[pid - 1]) for pid in training_ids[train_mask]]) | |
| scaler = StandardScaler().fit(X_train) | |
| clf = LogisticRegression(max_iter=2000) | |
| clf.fit(scaler.transform(X_train), y_train) | |
| gt_mask = ground_truth > 0 | |
| X_gt = bands[gt_mask] | |
| pred_gt = clf.predict(scaler.transform(X_gt)) | |
| y_gt = ground_truth[gt_mask] | |
| knn_ref_gt = knn_result[gt_mask] | |
| model_matrix = np.zeros((7, 7), dtype=np.int64) | |
| np.add.at(model_matrix, (y_gt - 1, pred_gt - 1), 1) | |
| model_oa = model_matrix.diagonal().sum() / model_matrix.sum() | |
| H, W, n_bands = bands.shape | |
| pred_full = clf.predict(scaler.transform(bands.reshape(-1, n_bands))).reshape(H, W) | |
| return model_oa, model_matrix, pred_gt, y_gt, knn_ref_gt, pred_full | |
| def train_and_predict_rf(student_labels): | |
| """Train a random forest on the student's 23 labeled polygons (using the | |
| real spectral bands), then evaluate it only at the Ground Truth pixel | |
| locations — same protocol as the KNN model, so the two are directly | |
| comparable. No feature scaling needed: tree splits are scale-invariant.""" | |
| bands = DATA['bands'] | |
| training_ids = DATA['training_ids'] | |
| ground_truth = DATA['ground_truth'] | |
| knn_result = DATA['knn_result'] | |
| train_mask = training_ids > 0 | |
| X_train = bands[train_mask] | |
| y_train = np.array([int(student_labels[pid - 1]) for pid in training_ids[train_mask]]) | |
| clf = RandomForestClassifier(n_estimators=200, n_jobs=-1, random_state=0) | |
| clf.fit(X_train, y_train) | |
| gt_mask = ground_truth > 0 | |
| X_gt = bands[gt_mask] | |
| pred_gt = clf.predict(X_gt) | |
| y_gt = ground_truth[gt_mask] | |
| knn_ref_gt = knn_result[gt_mask] | |
| model_matrix = np.zeros((7, 7), dtype=np.int64) | |
| np.add.at(model_matrix, (y_gt - 1, pred_gt - 1), 1) | |
| model_oa = model_matrix.diagonal().sum() / model_matrix.sum() | |
| H, W, n_bands = bands.shape | |
| pred_full = clf.predict(bands.reshape(-1, n_bands)).reshape(H, W) | |
| return model_oa, model_matrix, pred_gt, y_gt, knn_ref_gt, pred_full | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # Visualization helpers | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| def add_class_legend(fig): | |
| """Attach the color-to-class legend directly under a map figure, so it | |
| doesn't rely on the student remembering the table from the first tab.""" | |
| handles = [ | |
| Patch(facecolor=COLORS_RGB[i] / 255.0, edgecolor='none', label=f"{i} - {CLASSES[i]}") | |
| for i in range(1, 8) | |
| ] | |
| fig.legend(handles=handles, loc='lower center', ncol=4, fontsize=8, | |
| frameon=False, bbox_to_anchor=(0.5, -0.02)) | |
| def fig_confusion_matrix(matrix, oa, title, cmap='Blues'): | |
| """Generic 7×7 confusion matrix plot: predicted classes vs Ground Truth.""" | |
| short = ["Eau", "Vergers", "Δ-Cult.", "Bâti", "Irr.-Dés.", "Non-Irr.", "Sable"] | |
| fig, ax = plt.subplots(figsize=(9, 7)) | |
| row_tot = matrix.sum(axis=1, keepdims=True) | |
| pct = np.where(row_tot > 0, matrix / row_tot * 100, 0) | |
| im = ax.imshow(pct, cmap=cmap, vmin=0, vmax=100) | |
| plt.colorbar(im, ax=ax, label="% de la classe réelle", shrink=0.8) | |
| ax.set_xticks(range(7)); ax.set_yticks(range(7)) | |
| ax.set_xticklabels([f"C{i+1}\n{short[i]}" for i in range(7)], fontsize=8) | |
| ax.set_yticklabels([f"C{i+1}\n{short[i]}" for i in range(7)], fontsize=8) | |
| ax.set_xlabel("Classe prédite (KNN)", fontsize=11, labelpad=8) | |
| ax.set_ylabel("Classe réelle (vérité terrain)", fontsize=11, labelpad=8) | |
| ax.set_title(f"{title}\nPrécision globale = {oa*100:.1f}%", | |
| fontsize=12, fontweight='bold', pad=12) | |
| for r in range(7): | |
| for c in range(7): | |
| v, p = matrix[r, c], pct[r, c] | |
| color = 'white' if p > 50 else 'black' | |
| ax.text(c, r, f"{v}\n({p:.0f}%)", ha='center', va='center', | |
| fontsize=7, color=color) | |
| plt.tight_layout() | |
| return fig | |
| def fig_knn_matrix(): | |
| """Reference matrix: pre-computed KNN (trained on teacher labels) vs Ground Truth.""" | |
| return fig_confusion_matrix( | |
| DATA['knn_matrix'], DATA['knn_oa'], | |
| "Matrice de confusion – Modèle de référence (enseignant) vs Vérité terrain", | |
| cmap='Blues', | |
| ) | |
| def fig_model_matrix(model_matrix, model_oa): | |
| """Student's own trained KNN model vs Ground Truth.""" | |
| return fig_confusion_matrix( | |
| model_matrix, model_oa, | |
| "Matrice de confusion – VOTRE modèle KNN vs Vérité terrain", | |
| cmap='Purples', | |
| ) | |
| def fig_gt_maps(pred_gt, y_gt, knn_ref_gt, model_label="Votre modèle KNN"): | |
| """Point maps AT THE GROUND TRUTH PIXELS ONLY (the only place where the | |
| right answer is actually known) — Ground Truth / your model / reference | |
| model, plus a 4th panel showing exactly where your model is right (green) | |
| or wrong (red) vs Ground Truth. Only plotting these ~21k points (instead | |
| of classifying and coloring the whole image) is what makes it possible to | |
| actually see where the classification is correct.""" | |
| rows = DATA['gt_rows'] | |
| cols = DATA['gt_cols'] | |
| correct = (pred_gt == y_gt) | |
| fig, axes = plt.subplots(1, 4, figsize=(20, 5.5)) | |
| panels = [ | |
| (y_gt, "Vérité terrain", None), | |
| (pred_gt, model_label, None), | |
| (knn_ref_gt, "Modèle de référence (enseignant)", None), | |
| (None, f"{model_label} : correct (vert) / erreur (rouge)", correct), | |
| ] | |
| for ax, (classes, title, correctness) in zip(axes, panels): | |
| if correctness is None: | |
| colors = COLORS_RGB[classes] / 255.0 | |
| else: | |
| colors = np.where(correctness[:, None], [[0.15, 0.65, 0.2]], [[0.85, 0.1, 0.1]]) | |
| ax.scatter(cols, rows, c=colors, s=3, marker='s', linewidths=0) | |
| ax.set_title(title, fontsize=10, fontweight='bold') | |
| ax.set_xlim(cols.min() - 5, cols.max() + 5) | |
| ax.set_ylim(rows.max() + 5, rows.min() - 5) # inverted: row 0 = top | |
| ax.set_aspect('equal') | |
| ax.axis('off') | |
| if len(correct): | |
| acc = correct.mean() * 100 | |
| fig.suptitle(f"Précision de votre modèle sur les pixels de vérité terrain : {acc:.1f}%", | |
| fontsize=12, fontweight='bold') | |
| plt.tight_layout(rect=[0, 0.08, 1, 1]) | |
| add_class_legend(fig) | |
| return fig | |
| def fig_full_maps(*panels): | |
| """Full-image classification maps: EVERY pixel, not just the ~21k Ground | |
| Truth locations. A model classifies the whole image — but outside Ground | |
| Truth locations there is no known right answer, so accuracy can't be | |
| computed there. This is purely illustrative. | |
| panels: one or more (classes_2d, title) tuples.""" | |
| fig, axes = plt.subplots(1, len(panels), figsize=(7 * len(panels), 6.5)) | |
| if len(panels) == 1: | |
| axes = [axes] | |
| for ax, (classes, title) in zip(axes, panels): | |
| ax.imshow(COLORS_RGB[classes]) | |
| ax.set_title(title, fontsize=11, fontweight='bold') | |
| ax.axis('off') | |
| fig.suptitle( | |
| "Classification sur l'ensemble de l'image (précision non évaluable hors vérité terrain)", | |
| fontsize=11, | |
| ) | |
| plt.tight_layout(rect=[0, 0.1, 1, 0.95]) | |
| add_class_legend(fig) | |
| return fig | |
| def fig_student_matrix(student_labels): | |
| """7×7 confusion matrix: student labels vs teacher labels.""" | |
| matrix = np.zeros((7, 7), dtype=int) | |
| for pid in range(1, 24): | |
| s = student_labels[pid - 1] | |
| t = DATA['polygon_teacher'].get(pid, 0) | |
| if s is not None and t > 0: | |
| matrix[t - 1][int(s) - 1] += 1 | |
| short = ["Eau", "Vergers", "Δ-Cult.", "Bâti", "Irr.-Dés.", "Non-Irr.", "Sable"] | |
| fig, ax = plt.subplots(figsize=(8, 6)) | |
| row_tot = matrix.sum(axis=1, keepdims=True) | |
| pct = np.where(row_tot > 0, matrix / row_tot * 100, 0) | |
| im = ax.imshow(pct, cmap='Greens', vmin=0, vmax=100) | |
| plt.colorbar(im, ax=ax, label="% de la classe enseignant", shrink=0.8) | |
| ax.set_xticks(range(7)); ax.set_yticks(range(7)) | |
| ax.set_xticklabels([f"C{i+1}\n{short[i]}" for i in range(7)], fontsize=8) | |
| ax.set_yticklabels([f"C{i+1}\n{short[i]}" for i in range(7)], fontsize=8) | |
| ax.set_xlabel("Votre réponse", fontsize=11, labelpad=8) | |
| ax.set_ylabel("Réponse de l'enseignant", fontsize=11, labelpad=8) | |
| ax.set_title("Votre interprétation vs Réponse de l'enseignant\n(polygones d'entraînement)", | |
| fontsize=11, fontweight='bold', pad=12) | |
| for r in range(7): | |
| for c in range(7): | |
| v, p = matrix[r, c], pct[r, c] | |
| color = 'white' if p > 50 else 'black' | |
| if v > 0: | |
| ax.text(c, r, f"{v}\n({p:.0f}%)", ha='center', va='center', | |
| fontsize=8, color=color) | |
| plt.tight_layout() | |
| return fig | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # Gradio helpers | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| def polygon_image_path(idx): | |
| return os.path.join(BASE_DIR, 'polygon_images', f'polygon_{idx+1:02d}.png') | |
| def count_labeled(labels): | |
| return sum(1 for l in labels if l is not None) | |
| def build_results_table(student_labels): | |
| rows = [] | |
| for pid in range(1, 24): | |
| s = student_labels[pid - 1] | |
| t = DATA['polygon_teacher'].get(pid, 0) | |
| s_str = f"{s} – {CLASSES.get(int(s), '?')}" if s is not None else "—" | |
| t_str = f"{t} – {CLASSES.get(t, '?')}" | |
| match = "✅" if (s is not None and int(s) == t) else ("❌" if s is not None else "—") | |
| rows.append([pid, s_str, t_str, match]) | |
| return rows | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # Gradio Interface | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| with gr.Blocks(title="Interprétation de polygones – Vallée du Nil") as demo: | |
| # ── State ────────────────────────────────────────────────────────────── | |
| cur_idx = gr.State(value=0) | |
| labels = gr.State(value=[None] * N_POLYGONS) | |
| # ── Header ───────────────────────────────────────────────────────────── | |
| gr.Markdown(""" | |
| # Interprétation de polygones d'entraînement – Vallée du Nil | |
| **Objectif :** Interprétez visuellement chacun des 23 polygones d'entraînement, | |
| puis soumettez vos réponses pour générer la carte et la matrice de confusion. | |
| > Cette application s'inscrit dans un TD sur la classification d'occupation du sol par IA (algorithme KNN). | |
| """) | |
| with gr.Tabs() as tabs: | |
| # ── Tab 1: Légende des classes ──────────────────────────────────── | |
| with gr.Tab("Classes d'occupation du sol"): | |
| gr.Markdown(""" | |
| ## Classes d'occupation du sol | |
| | N° | Classe | Couleur | | |
| |----|--------|---------| | |
| | 1 | Eau | 🔵 Bleu | | |
| | 2 | Vergers | 🟢 Vert foncé | | |
| | 3 | Cultures dans le Delta | 💚 Vert clair | | |
| | 4 | Zones bâties | 🔴 Rouge | | |
| | 5 | Cultures irriguées dans le désert | 🟠 Orange | | |
| | 6 | Cultures non irriguées en zone sèche | 🟤 Marron | | |
| | 7 | Zones sableuses | 🟡 Jaune clair | | |
| --- | |
| **Conseils d'interprétation :** | |
| - L'image montre une composition colorée de l'image Landsat | |
| - Les zones bleues/sombres correspondent à l'eau | |
| - La végétation dense apparaît en vert (vergers, cultures dans le delta) | |
| - Les zones bâties apparaissent en teintes rosées ou grises | |
| - Les zones cultivées irriguées dans le désert forment des parcelles géométriques | |
| - Les zones sableuses apparaissent en jaune/beige | |
| """) | |
| # ── Tab 2: Interprétation ───────────────────────────────────────── | |
| with gr.Tab("Interprétation des polygones"): | |
| progress_md = gr.Markdown("**0 / 23 polygones étiquetés**") | |
| with gr.Row(): | |
| with gr.Column(scale=3): | |
| polygon_title = gr.Markdown("### Polygone 1 / 23") | |
| polygon_img = gr.Image( | |
| value=polygon_image_path(0), | |
| label="Image satellite", | |
| show_label=False, | |
| height=500, | |
| ) | |
| with gr.Row(): | |
| btn_prev = gr.Button("◀ Précédent", size="sm", variant="secondary") | |
| btn_next = gr.Button("Suivant ▶", size="sm", variant="primary") | |
| jump_dropdown = gr.Dropdown( | |
| choices=[str(i) for i in range(1, N_POLYGONS + 1)], | |
| value="1", | |
| label="Aller directement au polygone n°", | |
| ) | |
| gr.Markdown( | |
| "*(Vous pouvez revenir en arrière et changer une réponse " | |
| "déjà donnée autant de fois que vous voulez)*" | |
| ) | |
| with gr.Column(scale=1): | |
| gr.Markdown("### Classe du polygone") | |
| gr.Markdown("*(Cochez la classe qui correspond le mieux à l'occupation du sol visible dans le polygone)*") | |
| class_radio = gr.Radio( | |
| choices=CLASS_CHOICES, | |
| label="Classe", | |
| value=None, | |
| ) | |
| gr.Markdown("---") | |
| progress_bar = gr.Markdown("**Progression : 0 / 23**") | |
| btn_submit = gr.Button( | |
| "Voir ma précision d'interprétation", | |
| variant="primary", | |
| visible=False, | |
| size="lg", | |
| ) | |
| # ── Tab 3: Résultats de l'interprétation ────────────────────────── | |
| with gr.Tab("Résultats", id="tab_results") as tab_results: | |
| results_placeholder = gr.Markdown( | |
| "*(Les résultats apparaîtront ici après soumission)*" | |
| ) | |
| accuracy_md = gr.Markdown(visible=False) | |
| results_tbl = gr.Dataframe( | |
| headers=["Polygone", "Votre réponse", "Réponse enseignant", "Résultat"], | |
| visible=False, | |
| wrap=True, | |
| ) | |
| with gr.Row(visible=False) as row_plots_student: | |
| student_matrix_plot = gr.Plot(label="Votre interprétation vs Enseignant") | |
| # ── Tab 4: Régression logistique, algorithme de base pour comparaison ── | |
| with gr.Tab("Régression logistique (comparaison)"): | |
| gr.Markdown(""" | |
| ## Entraînez un modèle de régression logistique | |
| La régression logistique est un algorithme de classification : pour chaque | |
| pixel, elle calcule à partir des 7 bandes spectrales une probabilité | |
| d'appartenance à chacune des 7 classes, et retient la plus probable. | |
| Ce modèle est entraîné sur **vos** 23 étiquettes (onglet Interprétation) | |
| combinées aux vraies valeurs spectrales des 7 bandes satellite, puis évalué | |
| sur les pixels de vérité terrain (indépendants des polygones d'entraînement). | |
| """) | |
| logreg_warning_md = gr.Markdown(visible=False) | |
| btn_train_logreg = gr.Button( | |
| "Entraîner mon modèle de régression logistique", | |
| variant="primary", | |
| size="lg", | |
| ) | |
| logreg_accuracy_md = gr.Markdown(visible=False) | |
| with gr.Row(visible=False) as row_plots_logreg: | |
| logreg_matrix_plot = gr.Plot(label="Votre modèle de régression logistique vs Vérité terrain") | |
| logreg_knn_plot = gr.Plot(label="Modèle de référence (enseignant) vs Vérité terrain") | |
| logreg_map_plot = gr.Plot( | |
| label="Cartes aux emplacements de vérité terrain (seuls points où la vraie classe est connue)", | |
| visible=False, | |
| ) | |
| gr.Markdown( | |
| "*(Ci-dessous : les deux modèles classifient en réalité l'image entière — " | |
| "y compris les zones sans vérité terrain. On ne peut pas y vérifier si " | |
| "c'est correct, mais on peut voir à quoi ressemble la classification complète.)*", | |
| visible=True, | |
| ) | |
| logreg_full_plot = gr.Plot( | |
| label="Cartes de classification complètes (tous les pixels, précision non évaluable)", | |
| visible=False, | |
| ) | |
| # ── Tab 5: Entraînement du modèle KNN ────────────────────────────── | |
| with gr.Tab("Entraînement du modèle KNN"): | |
| gr.Markdown(""" | |
| ## Entraînez votre propre modèle KNN | |
| Ce modèle est entraîné sur **vos** 23 étiquettes (onglet Interprétation) | |
| combinées aux vraies valeurs spectrales des 7 bandes satellite, puis évalué | |
| sur les pixels de vérité terrain (indépendants des polygones d'entraînement). | |
| """) | |
| train_warning_md = gr.Markdown(visible=False) | |
| k_slider = gr.Slider( | |
| minimum=K_MIN, maximum=K_MAX, value=K_DEFAULT, step=1, | |
| label="k (nombre de voisins)", | |
| ) | |
| btn_train = gr.Button( | |
| "Entraîner mon modèle KNN", | |
| variant="primary", | |
| size="lg", | |
| ) | |
| model_accuracy_md = gr.Markdown(visible=False) | |
| with gr.Row(visible=False) as row_plots_model: | |
| model_matrix_plot = gr.Plot(label="Votre modèle KNN vs Vérité terrain") | |
| knn_plot = gr.Plot(label="Modèle de référence (enseignant) vs Vérité terrain") | |
| three_panel_plot = gr.Plot( | |
| label="Cartes aux emplacements de vérité terrain (seuls points où la vraie classe est connue)", | |
| visible=False, | |
| ) | |
| gr.Markdown( | |
| "*(Ci-dessous : les deux modèles classifient en réalité l'image entière — " | |
| "y compris les zones sans vérité terrain. On ne peut pas y vérifier si " | |
| "c'est correct, mais on peut voir à quoi ressemble la classification complète.)*", | |
| visible=True, | |
| ) | |
| full_maps_plot = gr.Plot( | |
| label="Cartes de classification complètes (tous les pixels, précision non évaluable)", | |
| visible=False, | |
| ) | |
| # ── Tab 6: Forêt aléatoire, second algorithme pour comparaison ───── | |
| with gr.Tab("Forêt aléatoire (comparaison)"): | |
| gr.Markdown(""" | |
| ## Entraînez un modèle de forêt aléatoire (Random Forest) | |
| Une forêt aléatoire entraîne un grand nombre d'arbres de décision, chacun sur | |
| un sous-échantillon aléatoire des données, puis fait voter l'ensemble : chaque | |
| pixel est classé selon la classe majoritaire parmi tous les arbres. | |
| Ce modèle est entraîné sur **vos** 23 étiquettes (onglet Interprétation) | |
| combinées aux vraies valeurs spectrales des 7 bandes satellite, puis évalué | |
| sur les pixels de vérité terrain (indépendants des polygones d'entraînement). | |
| """) | |
| rf_warning_md = gr.Markdown(visible=False) | |
| btn_train_rf = gr.Button( | |
| "Entraîner mon modèle de forêt aléatoire", | |
| variant="primary", | |
| size="lg", | |
| ) | |
| rf_accuracy_md = gr.Markdown(visible=False) | |
| with gr.Row(visible=False) as row_plots_rf: | |
| rf_matrix_plot = gr.Plot(label="Votre modèle de forêt aléatoire vs Vérité terrain") | |
| rf_knn_plot = gr.Plot(label="Modèle de référence (enseignant) vs Vérité terrain") | |
| rf_map_plot = gr.Plot( | |
| label="Cartes aux emplacements de vérité terrain (seuls points où la vraie classe est connue)", | |
| visible=False, | |
| ) | |
| gr.Markdown( | |
| "*(Ci-dessous : les deux modèles classifient en réalité l'image entière — " | |
| "y compris les zones sans vérité terrain. On ne peut pas y vérifier si " | |
| "c'est correct, mais on peut voir à quoi ressemble la classification complète.)*", | |
| visible=True, | |
| ) | |
| rf_full_plot = gr.Plot( | |
| label="Cartes de classification complètes (tous les pixels, précision non évaluable)", | |
| visible=False, | |
| ) | |
| # ───────────────────────────────────────────────────────────────────── | |
| # Event handlers | |
| # ───────────────────────────────────────────────────────────────────── | |
| def nav_to(idx, current_labels): | |
| """Render a polygon slide: image, title, current radio value.""" | |
| img = polygon_image_path(idx) | |
| title = f"### Polygone {idx + 1} / {N_POLYGONS}" | |
| saved = current_labels[idx] | |
| radio_val = None | |
| if saved is not None: | |
| radio_val = CLASS_CHOICES[int(saved) - 1] | |
| n_done = count_labeled(current_labels) | |
| prog = f"**Progression : {n_done} / {N_POLYGONS}**" | |
| return img, title, radio_val, prog, str(idx + 1) | |
| def on_prev(idx, current_labels): | |
| new_idx = max(0, idx - 1) | |
| img, title, radio_val, prog, jump_val = nav_to(new_idx, current_labels) | |
| return new_idx, img, title, radio_val, prog, jump_val | |
| def on_next(idx, current_labels): | |
| new_idx = min(N_POLYGONS - 1, idx + 1) | |
| img, title, radio_val, prog, jump_val = nav_to(new_idx, current_labels) | |
| return new_idx, img, title, radio_val, prog, jump_val | |
| def on_jump(choice, current_labels): | |
| """Go directly to the chosen polygon number (dropdown, freely re-visitable).""" | |
| new_idx = int(choice) - 1 | |
| img, title, radio_val, prog, _ = nav_to(new_idx, current_labels) | |
| return new_idx, img, title, radio_val, prog | |
| def on_class_select(choice, idx, current_labels): | |
| """Save selected class for current polygon.""" | |
| if choice is None: | |
| return current_labels, gr.update(), gr.update(), gr.update() | |
| cls_num = int(choice.split(" - ")[0]) | |
| new_labels = list(current_labels) | |
| new_labels[idx] = cls_num | |
| n_done = count_labeled(new_labels) | |
| prog = f"**Progression : {n_done} / {N_POLYGONS}**" | |
| overall= f"**{n_done} / {N_POLYGONS} polygones étiquetés**" | |
| show_submit = (n_done == N_POLYGONS) | |
| return (new_labels, | |
| gr.update(value=prog), | |
| gr.update(value=overall), | |
| gr.update(visible=show_submit)) | |
| def on_submit_labels(current_labels): | |
| """Show the accuracy of the visual interpretation vs the teacher's answer key.""" | |
| correct = sum( | |
| 1 for pid in range(1, 24) | |
| if current_labels[pid-1] is not None | |
| and int(current_labels[pid-1]) == DATA['polygon_teacher'].get(pid, 0) | |
| ) | |
| total = count_labeled(current_labels) | |
| pct = correct / total * 100 if total > 0 else 0 | |
| acc_text = ( | |
| f"**{correct} / {total} polygones correctement identifiés ({pct:.0f}%)**\n\n" | |
| f"*(Comparaison avec la légende fournie par l'enseignant)*" | |
| ) | |
| table = build_results_table(current_labels) | |
| student_fig = fig_student_matrix(current_labels) | |
| return ( | |
| gr.update(value=""), | |
| gr.update(value=acc_text, visible=True), | |
| gr.update(value=table, visible=True), | |
| gr.update(visible=True), | |
| gr.update(value=student_fig), | |
| ) | |
| def on_train(current_labels, k): | |
| """Train a KNN model on the student's own labels + real spectral bands, | |
| then evaluate it against Ground Truth.""" | |
| total = count_labeled(current_labels) | |
| if total < N_POLYGONS: | |
| warning = ( | |
| f"Vous n'avez étiqueté que {total}/{N_POLYGONS} polygones. " | |
| f"Terminez l'étiquetage dans l'onglet *Interprétation des polygones* " | |
| f"avant d'entraîner un modèle." | |
| ) | |
| return ( | |
| gr.update(value=warning, visible=True), | |
| gr.update(visible=False), | |
| gr.update(visible=False), | |
| gr.update(), | |
| gr.update(), | |
| gr.update(visible=False), | |
| gr.update(visible=False), | |
| ) | |
| model_oa, model_matrix, pred_gt, y_gt, knn_ref_gt, pred_full = train_and_predict( | |
| current_labels, k | |
| ) | |
| model_acc_text = ( | |
| f"**Précision globale = {model_oa*100:.1f}%** " | |
| f"(évaluée sur les {len(y_gt)} pixels de vérité terrain, k={int(k)})\n\n" | |
| f"*(C'est la précision réelle d'un modèle KNN entraîné uniquement sur VOS 23 polygones étiquetés — " | |
| f"comparez-la à celle du modèle de référence de l'enseignant ci-dessous)*" | |
| ) | |
| model_fig = fig_model_matrix(model_matrix, model_oa) | |
| knn_fig = fig_knn_matrix() | |
| map_fig = fig_gt_maps(pred_gt, y_gt, knn_ref_gt) | |
| full_fig = fig_full_maps( | |
| (pred_full, "Votre modèle KNN — carte complète"), | |
| (DATA['knn_result'], "Modèle de référence (enseignant) — carte complète"), | |
| ) | |
| return ( | |
| gr.update(visible=False), | |
| gr.update(value=model_acc_text, visible=True), | |
| gr.update(visible=True), | |
| gr.update(value=model_fig), | |
| gr.update(value=knn_fig), | |
| gr.update(value=map_fig, visible=True), | |
| gr.update(value=full_fig, visible=True), | |
| ) | |
| def on_train_logreg(current_labels): | |
| """Train a logistic regression model on the student's own labels, | |
| then evaluate it against Ground Truth — same layout as the KNN tab.""" | |
| total = count_labeled(current_labels) | |
| if total < N_POLYGONS: | |
| warning = ( | |
| f"Vous n'avez étiqueté que {total}/{N_POLYGONS} polygones. " | |
| f"Terminez l'étiquetage dans l'onglet *Interprétation des polygones* " | |
| f"avant d'entraîner un modèle." | |
| ) | |
| return ( | |
| gr.update(value=warning, visible=True), | |
| gr.update(visible=False), | |
| gr.update(visible=False), | |
| gr.update(), | |
| gr.update(), | |
| gr.update(visible=False), | |
| gr.update(visible=False), | |
| ) | |
| model_oa, model_matrix, pred_gt, y_gt, knn_ref_gt, pred_full = train_and_predict_logreg( | |
| current_labels | |
| ) | |
| model_acc_text = ( | |
| f"**Précision globale = {model_oa*100:.1f}%** " | |
| f"(évaluée sur les {len(y_gt)} pixels de vérité terrain)\n\n" | |
| f"*(C'est la précision réelle d'un modèle de régression logistique entraîné uniquement " | |
| f"sur VOS 23 polygones étiquetés — comparez-la à celle du modèle de référence de " | |
| f"l'enseignant ci-dessous, et à votre modèle KNN dans l'onglet suivant)*" | |
| ) | |
| model_fig = fig_confusion_matrix( | |
| model_matrix, model_oa, | |
| "Matrice de confusion – VOTRE modèle de régression logistique vs Vérité terrain", | |
| cmap='Reds', | |
| ) | |
| knn_fig = fig_knn_matrix() | |
| map_fig = fig_gt_maps(pred_gt, y_gt, knn_ref_gt, model_label="Votre modèle de régression logistique") | |
| full_fig = fig_full_maps( | |
| (pred_full, "Votre modèle de régression logistique — carte complète"), | |
| (DATA['knn_result'], "Modèle de référence (enseignant) — carte complète"), | |
| ) | |
| return ( | |
| gr.update(visible=False), | |
| gr.update(value=model_acc_text, visible=True), | |
| gr.update(visible=True), | |
| gr.update(value=model_fig), | |
| gr.update(value=knn_fig), | |
| gr.update(value=map_fig, visible=True), | |
| gr.update(value=full_fig, visible=True), | |
| ) | |
| def on_train_rf(current_labels): | |
| """Train a random forest on the student's own labels, then evaluate it | |
| against Ground Truth — same layout as the KNN tab.""" | |
| total = count_labeled(current_labels) | |
| if total < N_POLYGONS: | |
| warning = ( | |
| f"Vous n'avez étiqueté que {total}/{N_POLYGONS} polygones. " | |
| f"Terminez l'étiquetage dans l'onglet *Interprétation des polygones* " | |
| f"avant d'entraîner un modèle." | |
| ) | |
| return ( | |
| gr.update(value=warning, visible=True), | |
| gr.update(visible=False), | |
| gr.update(visible=False), | |
| gr.update(), | |
| gr.update(), | |
| gr.update(visible=False), | |
| gr.update(visible=False), | |
| ) | |
| model_oa, model_matrix, pred_gt, y_gt, knn_ref_gt, pred_full = train_and_predict_rf( | |
| current_labels | |
| ) | |
| model_acc_text = ( | |
| f"**Précision globale = {model_oa*100:.1f}%** " | |
| f"(évaluée sur les {len(y_gt)} pixels de vérité terrain)\n\n" | |
| f"*(C'est la précision réelle d'une forêt aléatoire entraînée uniquement sur VOS 23 " | |
| f"polygones étiquetés — comparez-la à celle du modèle de référence de l'enseignant " | |
| f"ci-dessous, et à vos autres modèles dans les onglets précédents)*" | |
| ) | |
| model_fig = fig_confusion_matrix( | |
| model_matrix, model_oa, | |
| "Matrice de confusion – VOTRE modèle de forêt aléatoire vs Vérité terrain", | |
| cmap='Oranges', | |
| ) | |
| knn_fig = fig_knn_matrix() | |
| map_fig = fig_gt_maps(pred_gt, y_gt, knn_ref_gt, model_label="Votre modèle de forêt aléatoire") | |
| full_fig = fig_full_maps( | |
| (pred_full, "Votre modèle de forêt aléatoire — carte complète"), | |
| (DATA['knn_result'], "Modèle de référence (enseignant) — carte complète"), | |
| ) | |
| return ( | |
| gr.update(visible=False), | |
| gr.update(value=model_acc_text, visible=True), | |
| gr.update(visible=True), | |
| gr.update(value=model_fig), | |
| gr.update(value=knn_fig), | |
| gr.update(value=map_fig, visible=True), | |
| gr.update(value=full_fig, visible=True), | |
| ) | |
| # Wire up navigation (Précédent / Suivant / direct jump-to-polygon dropdown — | |
| # all three can be used freely and repeatedly, in any order, at any time) | |
| btn_prev.click( | |
| on_prev, | |
| inputs=[cur_idx, labels], | |
| outputs=[cur_idx, polygon_img, polygon_title, class_radio, progress_bar, jump_dropdown], | |
| ) | |
| btn_next.click( | |
| on_next, | |
| inputs=[cur_idx, labels], | |
| outputs=[cur_idx, polygon_img, polygon_title, class_radio, progress_bar, jump_dropdown], | |
| ) | |
| # .input(), not .change(): btn_prev/btn_next also set jump_dropdown's displayed | |
| # value programmatically to keep it in sync, and .change() would otherwise | |
| # re-fire on_jump on every prev/next click too. | |
| jump_dropdown.input( | |
| on_jump, | |
| inputs=[jump_dropdown, labels], | |
| outputs=[cur_idx, polygon_img, polygon_title, class_radio, progress_bar], | |
| ) | |
| # Wire up class selection. IMPORTANT: use .input(), not .change() — on_prev/on_next | |
| # also set class_radio's displayed value when navigating (to reflect any previously | |
| # saved label), and .change() fires on *any* value update including that | |
| # programmatic one, which was overwriting/re-saving the wrong polygon's label and | |
| # made the last polygon's selection appear to never "stick". .input() only fires | |
| # on genuine user interaction — this is also what lets students freely revisit | |
| # and re-answer any polygon as many times as they like. | |
| class_radio.input( | |
| on_class_select, | |
| inputs=[class_radio, cur_idx, labels], | |
| outputs=[labels, progress_bar, progress_md, btn_submit], | |
| ) | |
| # Wire up submit (labeling accuracy only) | |
| btn_submit.click( | |
| on_submit_labels, | |
| inputs=[labels], | |
| outputs=[ | |
| results_placeholder, | |
| accuracy_md, | |
| results_tbl, | |
| row_plots_student, | |
| student_matrix_plot, | |
| ], | |
| ) | |
| # Wire up model training (separate tab, independent of the labeling submit) | |
| btn_train.click( | |
| on_train, | |
| inputs=[labels, k_slider], | |
| outputs=[ | |
| train_warning_md, | |
| model_accuracy_md, | |
| row_plots_model, | |
| model_matrix_plot, | |
| knn_plot, | |
| three_panel_plot, | |
| full_maps_plot, | |
| ], | |
| ) | |
| # Wire up logistic regression training (separate tab, for comparison against KNN) | |
| btn_train_logreg.click( | |
| on_train_logreg, | |
| inputs=[labels], | |
| outputs=[ | |
| logreg_warning_md, | |
| logreg_accuracy_md, | |
| row_plots_logreg, | |
| logreg_matrix_plot, | |
| logreg_knn_plot, | |
| logreg_map_plot, | |
| logreg_full_plot, | |
| ], | |
| ) | |
| # Wire up random forest training (separate tab, for comparison against KNN) | |
| btn_train_rf.click( | |
| on_train_rf, | |
| inputs=[labels], | |
| outputs=[ | |
| rf_warning_md, | |
| rf_accuracy_md, | |
| row_plots_rf, | |
| rf_matrix_plot, | |
| rf_knn_plot, | |
| rf_map_plot, | |
| rf_full_plot, | |
| ], | |
| ) | |
| if __name__ == "__main__": | |
| demo.launch(server_name="0.0.0.0", server_port=7860) | |