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)