Spaces:
Sleeping
Sleeping
| """ | |
| GeoAI Explorer - Streamlit App (datos reales) | |
| ================================================ | |
| Clasificacion de vegetacion y cobertura del suelo a partir de datos reales de | |
| teledeteccion Landsat MSS. | |
| Dataset historico real: Statlog (Landsat Satellite), UCI Machine Learning Repository (1993) | |
| - 6,435 pixeles reales extraidos de imagenes satelitales Landsat MSS | |
| - Cada fila = vecindario de 3x3 pixeles x 4 bandas espectrales (2 visibles + 2 infrarrojo cercano) | |
| - Etiqueta de cobertura del suelo del pixel central, asignada por fotointerpretacion experta | |
| - Fuente: Ashwin Srinivasan (1993), University of Strathclyde / UCI ML Repository | |
| https://archive.ics.uci.edu/dataset/146/statlog+landsat+satellite | |
| El archivo CSV real (statlog_landsat_satellite.csv) se incluye junto a esta app. | |
| Arquitectura: igual a los dashboards anteriores -> entrenamiento on-demand guardado | |
| en st.session_state, sin .joblib persistido en disco. | |
| """ | |
| import os | |
| import warnings | |
| import numpy as np | |
| import pandas as pd | |
| import matplotlib.pyplot as plt | |
| import seaborn as sns | |
| import streamlit as st | |
| from sklearn.ensemble import RandomForestClassifier | |
| from sklearn.inspection import permutation_importance | |
| from sklearn.metrics import ( | |
| accuracy_score, precision_score, recall_score, f1_score, | |
| roc_auc_score, roc_curve, confusion_matrix, classification_report, | |
| ) | |
| from sklearn.model_selection import train_test_split | |
| from sklearn.preprocessing import StandardScaler | |
| warnings.filterwarnings("ignore") | |
| st.set_page_config( | |
| page_title="GeoAI Explorer - Vegetacion y Cobertura del Suelo", | |
| page_icon="\U0001F30D", | |
| layout="wide", | |
| initial_sidebar_state="expanded", | |
| ) | |
| st.markdown(""" | |
| <style> | |
| .main-header { font-size: 2.3rem; font-weight: 800; color: #14532D; margin-bottom: 0.2rem; } | |
| .sub-header { font-size: 1rem; color: #555; margin-bottom: 1.5rem; } | |
| .section-title { | |
| font-size: 1.3rem; font-weight: 700; color: #1B3A4B; | |
| border-bottom: 2px solid #2E86AB; padding-bottom: 4px; margin-bottom: 1rem; | |
| } | |
| .info-box { | |
| background: #EAF4FB; border: 1px solid #AED6F1; border-radius: 6px; | |
| padding: 0.8rem 1rem; margin-bottom: 1rem; font-size: 0.9rem; | |
| } | |
| </style> | |
| """, unsafe_allow_html=True) | |
| RANDOM_STATE = 42 | |
| DATA_PATH = os.path.join(os.path.dirname(__file__), "statlog_landsat_satellite.csv") | |
| CLASS_NAMES = { | |
| 1: "Suelo rojo", 2: "Cultivo de algodon", 3: "Suelo gris", | |
| 4: "Suelo gris humedo", 5: "Suelo con rastrojo vegetal", 7: "Suelo gris muy humedo", | |
| } | |
| VEGETATION_CLASSES = {2, 5} # clases con presencia de vegetacion/cultivo | |
| CENTER_BANDS = ["px5_b1", "px5_b2", "px5_b3", "px5_b4"] # pixel central del vecindario 3x3 | |
| ALL_PIXEL_COLS = [f"px{p}_b{b}" for p in range(1, 10) for b in range(1, 5)] | |
| # ---------------------------------------------------------------------------- | |
| # Carga de datos reales (con respaldo sintetico solo si el archivo no esta) | |
| # ---------------------------------------------------------------------------- | |
| def load_landsat_data(file_bytes=None): | |
| if file_bytes is not None: | |
| import io | |
| return pd.read_csv(io.BytesIO(file_bytes)), "CSV subido por el usuario" | |
| if os.path.exists(DATA_PATH): | |
| return pd.read_csv(DATA_PATH), "Statlog (Landsat Satellite) - UCI ML Repository (datos reales, 1993)" | |
| return generate_synthetic_fallback(), "Generador sintetico de respaldo (archivo no encontrado)" | |
| def generate_synthetic_fallback(n=3000, seed=RANDOM_STATE): | |
| rng = np.random.default_rng(seed) | |
| classes = rng.choice([1, 2, 3, 4, 5, 7], n, p=[0.24, 0.11, 0.21, 0.10, 0.11, 0.23]) | |
| rows = [] | |
| for c in classes: | |
| base = {1: 100, 2: 70, 3: 90, 4: 80, 5: 75, 7: 85}[c] | |
| row = {f"px{p}_b{b}": int(np.clip(rng.normal(base, 15), 0, 255)) for p in range(1, 10) for b in range(1, 5)} | |
| row["class_code"] = c | |
| rows.append(row) | |
| return pd.DataFrame(rows) | |
| # ---------------------------------------------------------------------------- | |
| # Ingenieria de caracteristicas | |
| # ---------------------------------------------------------------------------- | |
| def engineer_features(df: pd.DataFrame) -> pd.DataFrame: | |
| out = df.copy() | |
| for b in range(1, 5): | |
| cols_b = [f"px{p}_b{b}" for p in range(1, 10)] | |
| out[f"mean_b{b}"] = out[cols_b].mean(axis=1) | |
| out[f"std_b{b}"] = out[cols_b].std(axis=1) | |
| # Indice tipo NDVI aproximado usando bandas 4 (NIR) y 3 (rojo) del Landsat MSS | |
| out["pseudo_ndvi"] = (out["px5_b4"] - out["px5_b3"]) / (out["px5_b4"] + out["px5_b3"] + 1e-3) | |
| out["brightness"] = out[CENTER_BANDS].sum(axis=1) | |
| out["vegetation"] = out["class_code"].isin(VEGETATION_CLASSES).astype(int) | |
| out["class_name"] = out["class_code"].map(CLASS_NAMES) | |
| return out | |
| ENGINEERED_COLS = ["mean_b1", "mean_b2", "mean_b3", "mean_b4", "std_b1", "std_b2", "std_b3", "std_b4", "pseudo_ndvi", "brightness"] | |
| VEG_FEATURES = CENTER_BANDS + ENGINEERED_COLS | |
| LC_FEATURES = CENTER_BANDS + ENGINEERED_COLS | |
| def train_vegetation_model(_df_fe: pd.DataFrame, n_estimators: int, test_size: float): | |
| X, y = _df_fe[VEG_FEATURES], _df_fe["vegetation"] | |
| X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=test_size, random_state=RANDOM_STATE, stratify=y) | |
| scaler = StandardScaler() | |
| X_train_s, X_test_s = scaler.fit_transform(X_train), scaler.transform(X_test) | |
| model = RandomForestClassifier(n_estimators=n_estimators, max_depth=12, class_weight="balanced", random_state=RANDOM_STATE, n_jobs=-1) | |
| model.fit(X_train_s, y_train) | |
| y_pred, y_prob = model.predict(X_test_s), model.predict_proba(X_test_s)[:, 1] | |
| metrics = { | |
| "acc": accuracy_score(y_test, y_pred), "precision": precision_score(y_test, y_pred), | |
| "recall": recall_score(y_test, y_pred), "f1": f1_score(y_test, y_pred), | |
| "roc_auc": roc_auc_score(y_test, y_prob), "cm": confusion_matrix(y_test, y_pred), | |
| "report": classification_report(y_test, y_pred, target_names=["Sin vegetacion", "Con vegetacion"]), | |
| "fpr_tpr": roc_curve(y_test, y_prob), | |
| } | |
| gini = pd.Series(model.feature_importances_, index=VEG_FEATURES).sort_values(ascending=False) | |
| perm = permutation_importance(model, X_test_s, y_test, n_repeats=10, random_state=RANDOM_STATE, n_jobs=-1) | |
| perm_imp = pd.Series(perm.importances_mean, index=VEG_FEATURES).sort_values(ascending=False) | |
| return model, scaler, metrics, gini, perm_imp | |
| def train_landcover_model(_df_fe: pd.DataFrame, n_estimators: int, test_size: float): | |
| X, y = _df_fe[LC_FEATURES], _df_fe["class_name"] | |
| X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=test_size, random_state=RANDOM_STATE, stratify=y) | |
| scaler = StandardScaler() | |
| X_train_s, X_test_s = scaler.fit_transform(X_train), scaler.transform(X_test) | |
| model = RandomForestClassifier(n_estimators=n_estimators, max_depth=14, class_weight="balanced", random_state=RANDOM_STATE, n_jobs=-1) | |
| model.fit(X_train_s, y_train) | |
| y_pred = model.predict(X_test_s) | |
| metrics = { | |
| "acc": accuracy_score(y_test, y_pred), "f1_macro": f1_score(y_test, y_pred, average="macro"), | |
| "f1_weighted": f1_score(y_test, y_pred, average="weighted"), | |
| "cm": confusion_matrix(y_test, y_pred, labels=model.classes_), "labels": model.classes_, | |
| "report": classification_report(y_test, y_pred), | |
| } | |
| gini = pd.Series(model.feature_importances_, index=LC_FEATURES).sort_values(ascending=False) | |
| perm = permutation_importance(model, X_test_s, y_test, n_repeats=10, random_state=RANDOM_STATE, n_jobs=-1, scoring="f1_macro") | |
| perm_imp = pd.Series(perm.importances_mean, index=LC_FEATURES).sort_values(ascending=False) | |
| return model, scaler, metrics, gini, perm_imp | |
| def predict_pixel(model, scaler, features, values: dict): | |
| row = pd.DataFrame([values])[features] | |
| row_s = scaler.transform(row) | |
| probs = model.predict_proba(row_s)[0] | |
| return dict(zip(model.classes_, probs)) | |
| # ---------------------------------------------------------------------------- | |
| # Sidebar | |
| # ---------------------------------------------------------------------------- | |
| with st.sidebar: | |
| st.markdown("## \u2699\ufe0f Configuracion") | |
| st.markdown("### \U0001F4C2 Fuente de datos") | |
| uploaded = st.file_uploader( | |
| "Sube un CSV alternativo (opcional)", type=["csv"], | |
| help="Si no subes nada, se usa el archivo real incluido statlog_landsat_satellite.csv " | |
| "(Statlog Landsat Satellite, UCI ML Repository, 1993)." | |
| ) | |
| raw_df, data_source = load_landsat_data(uploaded.read() if uploaded else None) | |
| df_fe = engineer_features(raw_df) | |
| st.markdown("### \U0001F916 Parametros del modelo") | |
| n_trees = st.slider("Arboles del Bosque Aleatorio", 50, 300, 200, step=25) | |
| test_frac = st.slider("Fraccion de test", 0.1, 0.4, 0.25, step=0.05) | |
| train_btn = st.button("\U0001F680 Entrenar ambos modelos", width='stretch', type="primary") | |
| for key in ["veg_model", "veg_scaler", "veg_metrics", "veg_gini", "veg_perm", | |
| "lc_model", "lc_scaler", "lc_metrics", "lc_gini", "lc_perm"]: | |
| if key not in st.session_state: | |
| st.session_state[key] = None | |
| if train_btn: | |
| with st.spinner("Entrenando modelo de vegetacion..."): | |
| m, s, met, gi, pi = train_vegetation_model(df_fe, n_trees, test_frac) | |
| st.session_state.update(veg_model=m, veg_scaler=s, veg_metrics=met, veg_gini=gi, veg_perm=pi) | |
| with st.spinner("Entrenando modelo de cobertura del suelo..."): | |
| m2, s2, met2, gi2, pi2 = train_landcover_model(df_fe, n_trees, test_frac) | |
| st.session_state.update(lc_model=m2, lc_scaler=s2, lc_metrics=met2, lc_gini=gi2, lc_perm=pi2) | |
| st.success("Modelos entrenados correctamente.") | |
| # ---------------------------------------------------------------------------- | |
| # Header | |
| # ---------------------------------------------------------------------------- | |
| st.markdown('<p class="main-header">\U0001F30D GeoAI Explorer: Vegetacion y Cobertura del Suelo</p>', unsafe_allow_html=True) | |
| st.markdown( | |
| f'<p class="sub-header">Clasificacion de pixeles satelitales Landsat MSS | ' | |
| f'Fuente: <b>{data_source}</b> | {len(df_fe):,} pixeles reales</p>', | |
| unsafe_allow_html=True, | |
| ) | |
| tab1, tab2, tab3, tab4, tab5 = st.tabs([ | |
| "\U0001F4CA EDA", "\U0001F33F Clasificacion de vegetacion", | |
| "\U0001F5FA Cobertura del suelo", "\U0001F52E Predictor de pixel", | |
| "\U0001F9E0 IA Explicable", | |
| ]) | |
| # ---------------------------------------------------------------------------- | |
| # TAB 1: EDA | |
| # ---------------------------------------------------------------------------- | |
| with tab1: | |
| st.markdown('<p class="section-title">Analisis Exploratorio de Datos</p>', unsafe_allow_html=True) | |
| c1, c2, c3, c4 = st.columns(4) | |
| c1.metric("Pixeles totales", f"{len(df_fe):,}") | |
| c2.metric("% con vegetacion", f"{df_fe['vegetation'].mean()*100:.1f}%") | |
| c3.metric("Clases de cobertura", df_fe["class_name"].nunique()) | |
| c4.metric("Pseudo-NDVI promedio", f"{df_fe['pseudo_ndvi'].mean():.3f}") | |
| col_a, col_b = st.columns(2) | |
| with col_a: | |
| st.markdown("**Distribucion de clases de cobertura del suelo (reales)**") | |
| fig, ax = plt.subplots(figsize=(6, 4)) | |
| order = df_fe["class_name"].value_counts().index | |
| sns.countplot(data=df_fe, y="class_name", order=order, color="#2E86AB", ax=ax) | |
| ax.set_xlabel("Cantidad de pixeles"); ax.set_ylabel("") | |
| fig.tight_layout(); st.pyplot(fig); plt.close(fig) | |
| with col_b: | |
| st.markdown("**Pseudo-NDVI por clase de cobertura**") | |
| fig, ax = plt.subplots(figsize=(6, 4)) | |
| sns.boxplot(data=df_fe, x="class_name", y="pseudo_ndvi", order=order, color="#74C69D", ax=ax) | |
| ax.tick_params(axis="x", rotation=25); ax.set_xlabel("") | |
| fig.tight_layout(); st.pyplot(fig); plt.close(fig) | |
| st.markdown("**Reflectancia promedio (pixel central) por banda Landsat MSS, segun clase**") | |
| melted = df_fe.melt(id_vars="class_name", value_vars=CENTER_BANDS, var_name="banda", value_name="reflectancia") | |
| fig, ax = plt.subplots(figsize=(12, 4.5)) | |
| sns.boxplot(data=melted, x="banda", y="reflectancia", hue="class_name", ax=ax) | |
| ax.legend(bbox_to_anchor=(1.01, 1), loc="upper left", fontsize=8) | |
| fig.tight_layout(); st.pyplot(fig); plt.close(fig) | |
| st.markdown("**Matriz de correlacion: bandas centrales, estadisticas del vecindario e indices**") | |
| fig, ax = plt.subplots(figsize=(9, 6)) | |
| corr = df_fe[CENTER_BANDS + ENGINEERED_COLS + ["vegetation"]].corr() | |
| sns.heatmap(corr, annot=True, fmt=".2f", cmap="RdBu_r", center=0, ax=ax) | |
| fig.tight_layout(); st.pyplot(fig); plt.close(fig) | |
| with st.expander("Vista previa de los datos reales"): | |
| st.dataframe(df_fe.head(200), width='stretch') | |
| # ---------------------------------------------------------------------------- | |
| # TAB 2: Vegetacion | |
| # ---------------------------------------------------------------------------- | |
| with tab2: | |
| st.markdown('<p class="section-title">Modelo de Clasificacion de Vegetacion (binario)</p>', unsafe_allow_html=True) | |
| st.caption("Clase positiva = pixeles fotointerpretados como cultivo de algodon o suelo con rastrojo vegetal.") | |
| if st.session_state.veg_metrics is None: | |
| st.markdown('<div class="info-box">Presiona <b>Entrenar ambos modelos</b> en la barra lateral para ver resultados.</div>', unsafe_allow_html=True) | |
| else: | |
| met = st.session_state.veg_metrics | |
| c1, c2, c3, c4, c5 = st.columns(5) | |
| c1.metric("Accuracy", f"{met['acc']*100:.1f}%") | |
| c2.metric("Precision", f"{met['precision']*100:.1f}%") | |
| c3.metric("Recall", f"{met['recall']*100:.1f}%") | |
| c4.metric("F1-score", f"{met['f1']*100:.1f}%") | |
| c5.metric("ROC AUC", f"{met['roc_auc']:.3f}") | |
| col_a, col_b = st.columns(2) | |
| with col_a: | |
| fig, ax = plt.subplots(figsize=(5, 4.5)) | |
| sns.heatmap(met["cm"], annot=True, fmt="d", cmap="Greens", ax=ax, | |
| xticklabels=["Sin veg.", "Con veg."], yticklabels=["Sin veg.", "Con veg."]) | |
| ax.set_xlabel("Prediccion"); ax.set_ylabel("Real"); ax.set_title("Matriz de confusion") | |
| fig.tight_layout(); st.pyplot(fig); plt.close(fig) | |
| with col_b: | |
| fpr, tpr, _ = met["fpr_tpr"] | |
| fig, ax = plt.subplots(figsize=(5, 4.5)) | |
| ax.plot(fpr, tpr, color="#2E7D32", linewidth=2, label=f"AUC = {met['roc_auc']:.3f}") | |
| ax.plot([0, 1], [0, 1], linestyle="--", color="gray", label="Azar") | |
| ax.set_xlabel("Falsos positivos"); ax.set_ylabel("Verdaderos positivos"); ax.set_title("Curva ROC"); ax.legend() | |
| fig.tight_layout(); st.pyplot(fig); plt.close(fig) | |
| with st.expander("Reporte de clasificacion completo"): | |
| st.text(met["report"]) | |
| # ---------------------------------------------------------------------------- | |
| # TAB 3: Cobertura del suelo | |
| # ---------------------------------------------------------------------------- | |
| with tab3: | |
| st.markdown('<p class="section-title">Modelo de Cobertura del Suelo (multiclase, 6 clases reales)</p>', unsafe_allow_html=True) | |
| if st.session_state.lc_metrics is None: | |
| st.markdown('<div class="info-box">Presiona <b>Entrenar ambos modelos</b> en la barra lateral para ver resultados.</div>', unsafe_allow_html=True) | |
| else: | |
| met = st.session_state.lc_metrics | |
| c1, c2, c3 = st.columns(3) | |
| c1.metric("Accuracy", f"{met['acc']*100:.1f}%") | |
| c2.metric("F1-macro", f"{met['f1_macro']*100:.1f}%") | |
| c3.metric("F1-weighted", f"{met['f1_weighted']*100:.1f}%") | |
| fig, ax = plt.subplots(figsize=(7, 6)) | |
| sns.heatmap(met["cm"], annot=True, fmt="d", cmap="Blues", ax=ax, | |
| xticklabels=met["labels"], yticklabels=met["labels"]) | |
| ax.set_xlabel("Prediccion"); ax.set_ylabel("Real"); ax.tick_params(axis="x", rotation=25) | |
| fig.tight_layout(); st.pyplot(fig); plt.close(fig) | |
| with st.expander("Reporte de clasificacion completo"): | |
| st.text(met["report"]) | |
| # ---------------------------------------------------------------------------- | |
| # TAB 4: Predictor de pixel | |
| # ---------------------------------------------------------------------------- | |
| with tab4: | |
| st.markdown('<p class="section-title">Predictor interactivo de pixel</p>', unsafe_allow_html=True) | |
| if st.session_state.veg_model is None: | |
| st.markdown('<div class="info-box">Entrena los modelos primero en la barra lateral.</div>', unsafe_allow_html=True) | |
| else: | |
| st.markdown("Ajusta las 4 bandas espectrales del pixel central (escala Landsat MSS, 0-255):") | |
| c1, c2, c3, c4 = st.columns(4) | |
| b1 = c1.slider("Banda 1 (verde)", 0, 255, 90) | |
| b2 = c2.slider("Banda 2 (rojo)", 0, 255, 110) | |
| b3 = c3.slider("Banda 3 (NIR)", 0, 255, 110) | |
| b4 = c4.slider("Banda 4 (NIR)", 0, 255, 90) | |
| vals = {"px5_b1": b1, "px5_b2": b2, "px5_b3": b3, "px5_b4": b4} | |
| for b, v in zip(range(1, 5), [b1, b2, b3, b4]): | |
| vals[f"mean_b{b}"] = v | |
| vals[f"std_b{b}"] = 5.0 | |
| vals["pseudo_ndvi"] = (b4 - b3) / (b4 + b3 + 1e-3) | |
| vals["brightness"] = b1 + b2 + b3 + b4 | |
| if st.button("\U0001F52E Predecir", type="primary"): | |
| veg_probs = predict_pixel(st.session_state.veg_model, st.session_state.veg_scaler, VEG_FEATURES, vals) | |
| lc_probs = predict_pixel(st.session_state.lc_model, st.session_state.lc_scaler, LC_FEATURES, vals) | |
| col_r1, col_r2 = st.columns(2) | |
| with col_r1: | |
| st.markdown("**Prediccion: presencia de vegetacion**") | |
| st.metric("Probabilidad de vegetacion", f"{veg_probs.get(1, 0)*100:.1f}%") | |
| st.progress(min(int(veg_probs.get(1, 0)*100), 100)) | |
| with col_r2: | |
| st.markdown("**Prediccion: tipo de cobertura del suelo**") | |
| pred_lc = max(lc_probs, key=lc_probs.get) | |
| st.metric("Clase mas probable", pred_lc) | |
| for cls, p in sorted(lc_probs.items(), key=lambda x: -x[1]): | |
| st.write(f"{cls}: {p*100:.1f}%") | |
| # ---------------------------------------------------------------------------- | |
| # TAB 5: IA Explicable | |
| # ---------------------------------------------------------------------------- | |
| with tab5: | |
| st.markdown('<p class="section-title">IA Explicable: Importancia de Variables</p>', unsafe_allow_html=True) | |
| if st.session_state.veg_gini is None: | |
| st.markdown('<div class="info-box">Entrena los modelos primero en la barra lateral.</div>', unsafe_allow_html=True) | |
| else: | |
| st.markdown("#### Clasificacion de vegetacion") | |
| col_a, col_b = st.columns(2) | |
| with col_a: | |
| fig, ax = plt.subplots(figsize=(6, 5)) | |
| colors = plt.cm.Greens_r(np.linspace(0.2, 0.8, len(st.session_state.veg_gini))) | |
| st.session_state.veg_gini.plot(kind="barh", ax=ax, color=colors) | |
| ax.invert_yaxis(); ax.set_xlabel("Importancia (impureza)") | |
| fig.tight_layout(); st.pyplot(fig); plt.close(fig) | |
| with col_b: | |
| fig, ax = plt.subplots(figsize=(6, 5)) | |
| colors = plt.cm.Oranges_r(np.linspace(0.2, 0.8, len(st.session_state.veg_perm))) | |
| st.session_state.veg_perm.plot(kind="barh", ax=ax, color=colors) | |
| ax.invert_yaxis(); ax.set_xlabel("Caida de F1 al permutar") | |
| fig.tight_layout(); st.pyplot(fig); plt.close(fig) | |
| st.markdown("#### Cobertura del suelo") | |
| col_c, col_d = st.columns(2) | |
| with col_c: | |
| fig, ax = plt.subplots(figsize=(6, 5)) | |
| colors = plt.cm.Blues_r(np.linspace(0.2, 0.8, len(st.session_state.lc_gini))) | |
| st.session_state.lc_gini.plot(kind="barh", ax=ax, color=colors) | |
| ax.invert_yaxis(); ax.set_xlabel("Importancia (impureza)") | |
| fig.tight_layout(); st.pyplot(fig); plt.close(fig) | |
| with col_d: | |
| fig, ax = plt.subplots(figsize=(6, 5)) | |
| colors = plt.cm.Purples_r(np.linspace(0.2, 0.8, len(st.session_state.lc_perm))) | |
| st.session_state.lc_perm.plot(kind="barh", ax=ax, color=colors) | |
| ax.invert_yaxis(); ax.set_xlabel("Caida de F1-macro al permutar") | |
| fig.tight_layout(); st.pyplot(fig); plt.close(fig) | |
| st.caption( | |
| "Las bandas del infrarrojo cercano (banda 4) y el pseudo-NDVI derivado de ellas suelen ser " | |
| "las variables mas relevantes para distinguir vegetacion (cultivos, rastrojo) de suelos " | |
| "desnudos, consistente con la fisica de la teledeteccion: la vegetacion sana refleja " | |
| "fuertemente el infrarrojo cercano." | |
| ) | |
| st.markdown("---") | |
| st.markdown( | |
| "<small>GeoAI Explorer. Dataset historico real: Statlog (Landsat Satellite), " | |
| "UCI Machine Learning Repository (1993) - archivo incluido statlog_landsat_satellite.csv.</small>", | |
| unsafe_allow_html=True, | |
| ) |