Spaces:
Sleeping
Sleeping
| # -*- coding: utf-8 -*- | |
| """Enhanced CI Outcome Predictor with Advanced UI (HF Spaces Ready)""" | |
| import os | |
| import re | |
| import pickle | |
| import joblib | |
| import numpy as np | |
| import pandas as pd | |
| import gradio as gr | |
| from pathlib import Path | |
| # ========================= | |
| # PATHS (Hugging Face Spaces / repo root) | |
| # Place these files in the SAME folder as app.py: | |
| # - validation_data.csv | |
| # - Cochlear_Implant_Dataset.csv | |
| # - ci_success_classifier.pkl | |
| # - ci_speech_score_regressor.pkl | |
| # ========================= | |
| BASE_DIR = Path(__file__).resolve().parent if "__file__" in globals() else Path.cwd() | |
| VAL_CSV_PATH = BASE_DIR / "validation_data.csv" | |
| MAIN_CSV_PATH = BASE_DIR / "Cochlear_Implant_Dataset.csv" | |
| CLF_PKL_PATH = BASE_DIR / "ci_success_classifier.pkl" | |
| REG_PKL_PATH = BASE_DIR / "ci_speech_score_regressor.pkl" | |
| # Writable location on HF Spaces | |
| BATCH_OUT_PATH = Path("/tmp/predictions_output.csv") | |
| def _require(p: Path) -> bool: | |
| return p.exists() and p.is_file() | |
| def _missing_files_message(): | |
| missing = [] | |
| for p in [VAL_CSV_PATH, MAIN_CSV_PATH, CLF_PKL_PATH, REG_PKL_PATH]: | |
| if not _require(p): | |
| missing.append(p.name) | |
| return f""" | |
| <div class="info-box"> | |
| <strong>β οΈ Setup required:</strong> Missing required files in repo root:<br/> | |
| <div style="margin-top:8px; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;"> | |
| {"<br/>".join(missing) if missing else "β"} | |
| </div> | |
| <div style="margin-top:10px; opacity:0.9;"> | |
| Upload the files to the <b>same folder as app.py</b> and restart the Space. | |
| </div> | |
| </div> | |
| """ | |
| # ========================= | |
| # Load data + models (guarded so Space can boot even if files missing) | |
| # ========================= | |
| APP_READY = all(_require(p) for p in [VAL_CSV_PATH, MAIN_CSV_PATH, CLF_PKL_PATH, REG_PKL_PATH]) | |
| val_df = pd.DataFrame() | |
| main_df = pd.DataFrame() | |
| clf_model = None | |
| reg_model = None | |
| if APP_READY: | |
| val_df = pd.read_csv(VAL_CSV_PATH) | |
| main_df = pd.read_csv(MAIN_CSV_PATH) | |
| def load_model(path: Path): | |
| try: | |
| return joblib.load(path) | |
| except Exception: | |
| with open(path, "rb") as f: | |
| return pickle.load(f) | |
| clf_model = load_model(CLF_PKL_PATH) | |
| reg_model = load_model(REG_PKL_PATH) | |
| def get_model_feature_names(m): | |
| if m is None: | |
| return None | |
| if hasattr(m, "feature_names_in_"): | |
| return list(getattr(m, "feature_names_in_")) | |
| if hasattr(m, "named_steps"): | |
| for step in m.named_steps.values(): | |
| if hasattr(step, "feature_names_in_"): | |
| return list(step.feature_names_in_) | |
| return None | |
| clf_expected = get_model_feature_names(clf_model) or [] | |
| reg_expected = get_model_feature_names(reg_model) or [] | |
| # Union of expected columns (preserve order) | |
| input_cols = [] | |
| for colset in [clf_expected, reg_expected]: | |
| for c in colset: | |
| if c not in input_cols: | |
| input_cols.append(c) | |
| if not input_cols and APP_READY: | |
| input_cols = list(val_df.columns) | |
| # ========================= | |
| # Build Gene dropdown choices from MAIN dataset | |
| # ========================= | |
| def find_gene_column(df: pd.DataFrame): | |
| if df is None or df.empty: | |
| return None | |
| if "Gene" in df.columns: | |
| return "Gene" | |
| for c in df.columns: | |
| if "gene" in c.lower(): | |
| return c | |
| return None | |
| def normalize_str_series(s: pd.Series) -> pd.Series: | |
| return ( | |
| s.astype(str) | |
| .str.strip() | |
| .replace({"null": np.nan, "NULL": np.nan, "None": np.nan, "none": np.nan, | |
| "": np.nan, "nan": np.nan, "NaN": np.nan}) | |
| ) | |
| gene_choices = [] | |
| if APP_READY: | |
| gene_col_main = find_gene_column(main_df) | |
| if gene_col_main is not None: | |
| gene_choices = sorted(set(normalize_str_series(main_df[gene_col_main]).dropna().tolist())) | |
| if not gene_choices: | |
| gene_col_val = find_gene_column(val_df) | |
| if gene_col_val is not None: | |
| gene_choices = sorted(set(normalize_str_series(val_df[gene_col_val]).dropna().tolist())) | |
| # ========================= | |
| # Helpers | |
| # ========================= | |
| def parse_age_to_years(age_raw: str, mode: str): | |
| """ | |
| mode: | |
| - "Years.Months (1.11 = 1y 11m)" -> 1 + 11/12 | |
| - "Decimal (1.11 = 1.11 years)" -> 1.11 | |
| Accepts "1.6YRS", "2yrs", etc. | |
| """ | |
| if age_raw is None: | |
| return np.nan | |
| s = str(age_raw).strip() | |
| if s == "" or s.lower() in {"nan", "none", "null"}: | |
| return np.nan | |
| cleaned = re.sub(r"[^0-9\.]", "", s) | |
| if mode.startswith("Decimal"): | |
| try: | |
| return float(cleaned) | |
| except: | |
| return np.nan | |
| # Years.Months mode | |
| if cleaned.count(".") == 1: | |
| a, b = cleaned.split(".") | |
| if a.isdigit() and b.isdigit() and len(b) == 2: | |
| years = int(a) | |
| months = int(b) | |
| if 0 <= months <= 11: | |
| return years + months / 12.0 | |
| # fallback to decimal | |
| try: | |
| return float(cleaned) | |
| except: | |
| return np.nan | |
| try: | |
| return float(cleaned) | |
| except: | |
| return np.nan | |
| def safe_pct(x): | |
| try: | |
| return int(round(float(x) * 100)) | |
| except: | |
| return None | |
| def get_gene_feature_name(cols): | |
| for c in cols: | |
| if c.lower() == "gene": | |
| return c | |
| for c in cols: | |
| if "gene" in c.lower(): | |
| return c | |
| return None | |
| def get_age_feature_names(cols): | |
| return [c for c in cols if "age" in c.lower()] | |
| GENE_FEAT = get_gene_feature_name(input_cols) if APP_READY else None | |
| AGE_FEATS = get_age_feature_names(input_cols) if APP_READY else [] | |
| def align_to_expected(df: pd.DataFrame, expected_cols): | |
| if not expected_cols: | |
| return df | |
| out = df.copy() | |
| for c in expected_cols: | |
| if c not in out.columns: | |
| out[c] = np.nan | |
| return out[expected_cols] | |
| def render_single_result_html(gene, age_entered, age_used_years, parse_mode, label, prob, speech): | |
| if label == 1: | |
| status = "Likely Success" | |
| badge = "ok" | |
| icon = "β" | |
| emoji = "π" | |
| elif label == 0: | |
| status = "Lower Likelihood" | |
| badge = "warn" | |
| icon = "!" | |
| emoji = "β οΈ" | |
| else: | |
| status = "Unavailable" | |
| badge = "neutral" | |
| icon = "?" | |
| emoji = "β" | |
| prob_pct = safe_pct(prob) if prob is not None else None | |
| prob_text = f"{prob_pct}%" if prob_pct is not None else "β" | |
| bar_width = f"{prob_pct}%" if prob_pct is not None else "0%" | |
| try: | |
| speech_disp = f"{float(speech):.3f}" | |
| except: | |
| speech_disp = "β" | |
| age_used_disp = f"{float(age_used_years):.3f} years" if np.isfinite(age_used_years) else "β" | |
| gene_disp = str(gene) if gene is not None else "β" | |
| return f""" | |
| <div class="result-card fade-in-up"> | |
| <div class="result-banner {badge}"> | |
| <div class="banner-emoji">{emoji}</div> | |
| <div class="banner-text"> | |
| <div class="banner-title">{status}</div> | |
| <div class="banner-sub">Prediction Complete</div> | |
| </div> | |
| <div class="pulse-dot"></div> | |
| </div> | |
| <div class="metrics-grid"> | |
| <div class="metric-card glass-effect hover-lift"> | |
| <div class="metric-icon">π§¬</div> | |
| <div class="metric-label">Gene</div> | |
| <div class="metric-value">{gene_disp}</div> | |
| </div> | |
| <div class="metric-card glass-effect hover-lift"> | |
| <div class="metric-icon">π </div> | |
| <div class="metric-label">Age Entered</div> | |
| <div class="metric-value">{age_entered}</div> | |
| </div> | |
| <div class="metric-card glass-effect hover-lift"> | |
| <div class="metric-icon">π¬</div> | |
| <div class="metric-label">Model Age</div> | |
| <div class="metric-value">{age_used_disp}</div> | |
| </div> | |
| <div class="metric-card glass-effect hover-lift"> | |
| <div class="metric-icon">π</div> | |
| <div class="metric-label">Parse Mode</div> | |
| <div class="metric-value small">{parse_mode.split('(')[0].strip()}</div> | |
| </div> | |
| </div> | |
| <div class="probability-section glass-effect"> | |
| <div class="prob-header"> | |
| <span class="prob-title">Success Probability</span> | |
| <span class="prob-value-large">{prob_text}</span> | |
| </div> | |
| <div class="prob-bar-container"> | |
| <div class="prob-bar-track"> | |
| <div class="prob-bar-fill {badge}" style="width:{bar_width};"> | |
| <div class="shimmer"></div> | |
| </div> | |
| </div> | |
| <div class="prob-markers"> | |
| <span>0%</span><span>25%</span><span>50%</span><span>75%</span><span>100%</span> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="details-grid"> | |
| <div class="detail-box glass-effect hover-lift"> | |
| <div class="detail-icon {badge}"> | |
| <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> | |
| <path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/> | |
| </svg> | |
| </div> | |
| <div class="detail-content"> | |
| <div class="detail-label">Predicted Label</div> | |
| <div class="detail-value">{label}</div> | |
| </div> | |
| </div> | |
| <div class="detail-box glass-effect hover-lift"> | |
| <div class="detail-icon {badge}"> | |
| <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> | |
| <path d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"/> | |
| </svg> | |
| </div> | |
| <div class="detail-content"> | |
| <div class="detail-label">Speech Score</div> | |
| <div class="detail-value">{speech_disp}</div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="disclaimer"> | |
| <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> | |
| <circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/> | |
| </svg> | |
| <span>Informational tool only. Not medical advice. Consult healthcare professionals for clinical decisions.</span> | |
| </div> | |
| </div> | |
| """ | |
| def predict_single(gene, age_text, parse_mode): | |
| if not APP_READY: | |
| return _missing_files_message() | |
| if gene is None or str(gene).strip() == "": | |
| raise gr.Error("Please select a Gene.") | |
| age_used = parse_age_to_years(age_text, parse_mode) | |
| if not (isinstance(age_used, (float, np.floating)) and np.isfinite(age_used)): | |
| raise gr.Error("Please enter a valid Age (e.g., 1.6YRS, 1.11, 2.3).") | |
| row = {} | |
| for c in input_cols: | |
| if GENE_FEAT and c == GENE_FEAT: | |
| row[c] = gene | |
| elif c in AGE_FEATS: | |
| row[c] = age_used | |
| else: | |
| row[c] = np.nan | |
| X = pd.DataFrame([row]) | |
| Xc = align_to_expected(X, clf_expected) | |
| Xr = align_to_expected(X, reg_expected) | |
| label = int(clf_model.predict(Xc)[0]) | |
| prob = None | |
| if hasattr(clf_model, "predict_proba"): | |
| p = clf_model.predict_proba(Xc)[0] | |
| if len(p) >= 2: | |
| prob = float(p[1]) | |
| speech = reg_model.predict(Xr)[0] | |
| return render_single_result_html(gene, age_text, age_used, parse_mode, label, prob, speech) | |
| def _file_to_path(file_obj): | |
| """ | |
| Gradio v3/v4 compatibility: | |
| - Sometimes a string path | |
| - Sometimes object with .name | |
| - In Gradio 4: often has .path | |
| - Sometimes dict-like with 'name' or 'path' | |
| """ | |
| if file_obj is None: | |
| return None | |
| if isinstance(file_obj, str): | |
| return file_obj | |
| if hasattr(file_obj, "path") and file_obj.path: | |
| return file_obj.path | |
| if hasattr(file_obj, "name") and file_obj.name: | |
| return file_obj.name | |
| if isinstance(file_obj, dict): | |
| if file_obj.get("path"): | |
| return file_obj["path"] | |
| if file_obj.get("name"): | |
| return file_obj["name"] | |
| return None | |
| def predict_batch(csv_file, parse_mode): | |
| if not APP_READY: | |
| raise gr.Error("Missing required model/data files in repo root. Please upload them and restart the Space.") | |
| path = _file_to_path(csv_file) | |
| if not path: | |
| raise gr.Error("Please upload a CSV file.") | |
| df = pd.read_csv(path) | |
| if df.empty: | |
| raise gr.Error("Uploaded CSV is empty.") | |
| df_cols_lower = {c.lower(): c for c in df.columns} | |
| gene_col = None | |
| if GENE_FEAT and GENE_FEAT.lower() in df_cols_lower: | |
| gene_col = df_cols_lower[GENE_FEAT.lower()] | |
| else: | |
| for c in df.columns: | |
| if "gene" in c.lower(): | |
| gene_col = c | |
| break | |
| if gene_col is None: | |
| raise gr.Error("CSV must include a Gene column (e.g., 'Gene').") | |
| age_source_col = None | |
| for c in df.columns: | |
| if "age" in c.lower(): | |
| age_source_col = c | |
| break | |
| if age_source_col is None: | |
| raise gr.Error("CSV must include an Age column (e.g., 'Age').") | |
| X = pd.DataFrame(index=df.index) | |
| parsed_age = df[age_source_col].apply(lambda v: parse_age_to_years(v, parse_mode)) | |
| if parsed_age.isna().any(): | |
| bad_n = int(parsed_age.isna().sum()) | |
| raise gr.Error(f"{bad_n} rows have invalid Age values for the selected parsing mode.") | |
| for col in input_cols: | |
| if GENE_FEAT and col == GENE_FEAT: | |
| X[col] = df[gene_col] | |
| elif col in AGE_FEATS: | |
| X[col] = parsed_age | |
| else: | |
| src = df_cols_lower.get(col.lower()) | |
| X[col] = df[src] if src is not None else np.nan | |
| Xc = align_to_expected(X, clf_expected) | |
| Xr = align_to_expected(X, reg_expected) | |
| out = df.copy() | |
| out["success_label_pred"] = clf_model.predict(Xc) | |
| if hasattr(clf_model, "predict_proba"): | |
| proba = clf_model.predict_proba(Xc) | |
| if proba.shape[1] == 2: | |
| out["success_prob_class1"] = proba[:, 1] | |
| out["speech_score_pred"] = reg_model.predict(Xr) | |
| out.to_csv(BATCH_OUT_PATH, index=False) | |
| n = len(out) | |
| succ = int((out["success_label_pred"] == 1).sum()) | |
| succ_pct = int(round((succ / n) * 100)) if n else 0 | |
| avg_prob_txt = "β" | |
| if "success_prob_class1" in out.columns: | |
| try: | |
| avg_prob_txt = f"{int(round(float(out['success_prob_class1'].mean())*100))}%" | |
| except: | |
| pass | |
| avg_speech_txt = "β" | |
| try: | |
| avg_speech_txt = f"{float(pd.to_numeric(out['speech_score_pred'], errors='coerce').mean()):.3f}" | |
| except: | |
| pass | |
| summary = f""" | |
| <div class="batch-results fade-in-up"> | |
| <div class="batch-header"> | |
| <div class="batch-title"> | |
| <span class="batch-icon">π</span> | |
| <span>Batch Analysis Complete</span> | |
| </div> | |
| <div class="batch-count">{n} rows processed</div> | |
| </div> | |
| <div class="stats-showcase"> | |
| <div class="stat-mega glass-effect hover-lift"> | |
| <div class="stat-mega-icon ok">β</div> | |
| <div class="stat-mega-content"> | |
| <div class="stat-mega-label">Predicted Success</div> | |
| <div class="stat-mega-value">{succ}</div> | |
| <div class="stat-mega-sub">{succ_pct}% of total</div> | |
| </div> | |
| </div> | |
| <div class="stats-grid"> | |
| <div class="stat-card glass-effect hover-lift"> | |
| <div class="stat-icon">π―</div> | |
| <div class="stat-label">Avg Probability</div> | |
| <div class="stat-value">{avg_prob_txt}</div> | |
| </div> | |
| <div class="stat-card glass-effect hover-lift"> | |
| <div class="stat-icon">π€</div> | |
| <div class="stat-label">Avg Speech Score</div> | |
| <div class="stat-value">{avg_speech_txt}</div> | |
| </div> | |
| <div class="stat-card glass-effect hover-lift"> | |
| <div class="stat-icon">βοΈ</div> | |
| <div class="stat-label">Parse Mode</div> | |
| <div class="stat-value small">{parse_mode.split('(')[0].strip()}</div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="download-prompt"> | |
| <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> | |
| <path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3"/> | |
| </svg> | |
| <span>Download the complete results CSV below</span> | |
| </div> | |
| </div> | |
| """ | |
| return summary, out.head(20), str(BATCH_OUT_PATH) | |
| def age_preview(age_text, parse_mode): | |
| v = parse_age_to_years(age_text, parse_mode) | |
| if isinstance(v, (float, np.floating)) and np.isfinite(v): | |
| return f""" | |
| <div class='age-preview-box fade-in'> | |
| <div class="preview-icon">β¨</div> | |
| <div class="preview-content"> | |
| <div class="preview-label">Model will use</div> | |
| <div class="preview-value">{v:.3f} years</div> | |
| </div> | |
| </div> | |
| """ | |
| return """ | |
| <div class='age-preview-box fade-in'> | |
| <div class="preview-icon">β</div> | |
| <div class="preview-content"> | |
| <div class="preview-label">Model will use</div> | |
| <div class="preview-value">Invalid</div> | |
| </div> | |
| </div> | |
| """ | |
| # ========================= | |
| # CSS + JS (UNCHANGED from your provided script) | |
| # ========================= | |
| # ========================= | |
| # ENHANCED CSS WITH LIGHT/DARK MODE & ANIMATIONS | |
| # ========================= | |
| CSS = """ | |
| /* ========== THEME VARIABLES ========== */ | |
| :root { | |
| --bg-primary: #f8fafc; | |
| --bg-secondary: #ffffff; | |
| --bg-tertiary: #f1f5f9; | |
| --text-primary: #0f172a; | |
| --text-secondary: #475569; | |
| --text-muted: #94a3b8; | |
| --border-color: #e2e8f0; | |
| --accent-primary: #3b82f6; | |
| --accent-secondary: #8b5cf6; | |
| --success-color: #10b981; | |
| --warning-color: #f59e0b; | |
| --danger-color: #ef4444; | |
| --shadow-sm: 0 1px 3px rgba(0,0,0,0.05); | |
| --shadow-md: 0 4px 12px rgba(0,0,0,0.08); | |
| --shadow-lg: 0 10px 30px rgba(0,0,0,0.12); | |
| --shadow-xl: 0 20px 50px rgba(0,0,0,0.15); | |
| --radius-sm: 8px; | |
| --radius-md: 12px; | |
| --radius-lg: 16px; | |
| --radius-xl: 24px; | |
| --transition-fast: 0.15s ease; | |
| --transition-normal: 0.3s ease; | |
| --transition-slow: 0.5s ease; | |
| } | |
| [data-theme="dark"] { | |
| --bg-primary: #0f172a; | |
| --bg-secondary: #1e293b; | |
| --bg-tertiary: #334155; | |
| --text-primary: #f1f5f9; | |
| --text-secondary: #cbd5e1; | |
| --text-muted: #64748b; | |
| --border-color: #334155; | |
| --shadow-sm: 0 1px 3px rgba(0,0,0,0.3); | |
| --shadow-md: 0 4px 12px rgba(0,0,0,0.4); | |
| --shadow-lg: 0 10px 30px rgba(0,0,0,0.5); | |
| --shadow-xl: 0 20px 50px rgba(0,0,0,0.6); | |
| } | |
| /* ========== BASE STYLES ========== */ | |
| * { | |
| transition: background-color var(--transition-normal), | |
| border-color var(--transition-normal), | |
| color var(--transition-normal); | |
| } | |
| .gradio-container { | |
| background: var(--bg-primary) !important; | |
| color: var(--text-primary) !important; | |
| font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif !important; | |
| } | |
| /* Hide Gradio footer */ | |
| footer, .footer, #footer, .gradio-footer { | |
| display: none !important; | |
| } | |
| /* ========== ANIMATIONS ========== */ | |
| @keyframes fadeInUp { | |
| from { | |
| opacity: 0; | |
| transform: translateY(20px); | |
| } | |
| to { | |
| opacity: 1; | |
| transform: translateY(0); | |
| } | |
| } | |
| @keyframes fadeIn { | |
| from { opacity: 0; } | |
| to { opacity: 1; } | |
| } | |
| @keyframes pulse { | |
| 0%, 100% { opacity: 1; } | |
| 50% { opacity: 0.5; } | |
| } | |
| @keyframes shimmer { | |
| 0% { transform: translateX(-100%); } | |
| 100% { transform: translateX(100%); } | |
| } | |
| @keyframes rotate { | |
| from { transform: rotate(0deg); } | |
| to { transform: rotate(360deg); } | |
| } | |
| @keyframes scaleIn { | |
| from { | |
| opacity: 0; | |
| transform: scale(0.9); | |
| } | |
| to { | |
| opacity: 1; | |
| transform: scale(1); | |
| } | |
| } | |
| @keyframes slideInRight { | |
| from { | |
| opacity: 0; | |
| transform: translateX(30px); | |
| } | |
| to { | |
| opacity: 1; | |
| transform: translateX(0); | |
| } | |
| } | |
| .fade-in-up { | |
| animation: fadeInUp 0.6s ease-out; | |
| } | |
| .fade-in { | |
| animation: fadeIn 0.4s ease-out; | |
| } | |
| .scale-in { | |
| animation: scaleIn 0.4s ease-out; | |
| } | |
| .slide-in-right { | |
| animation: slideInRight 0.5s ease-out; | |
| } | |
| /* ========== GLASS EFFECT ========== */ | |
| .glass-effect { | |
| background: rgba(255, 255, 255, 0.7) !important; | |
| backdrop-filter: blur(10px); | |
| -webkit-backdrop-filter: blur(10px); | |
| border: 1px solid rgba(255, 255, 255, 0.3); | |
| } | |
| [data-theme="dark"] .glass-effect { | |
| background: rgba(30, 41, 59, 0.7) !important; | |
| border: 1px solid rgba(255, 255, 255, 0.1); | |
| } | |
| /* ========== HOVER EFFECTS ========== */ | |
| .hover-lift { | |
| transition: transform var(--transition-normal), box-shadow var(--transition-normal); | |
| } | |
| .hover-lift:hover { | |
| transform: translateY(-4px); | |
| box-shadow: var(--shadow-lg) !important; | |
| } | |
| /* ========== HEADER ========== */ | |
| .app-header { | |
| background: linear-gradient(135deg, var(--accent-primary) 0%, var(--accent-secondary) 100%); | |
| padding: 48px 32px; | |
| border-radius: var(--radius-xl); | |
| box-shadow: var(--shadow-xl); | |
| margin-bottom: 32px; | |
| position: relative; | |
| overflow: hidden; | |
| animation: fadeInUp 0.6s ease-out; | |
| } | |
| .app-header::before { | |
| content: ''; | |
| position: absolute; | |
| top: -50%; | |
| right: -10%; | |
| width: 300px; | |
| height: 300px; | |
| background: rgba(255, 255, 255, 0.1); | |
| border-radius: 50%; | |
| animation: pulse 3s ease-in-out infinite; | |
| } | |
| .header-content { | |
| position: relative; | |
| z-index: 1; | |
| } | |
| .header-top { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| margin-bottom: 24px; | |
| } | |
| .header-title { | |
| display: flex; | |
| align-items: center; | |
| gap: 16px; | |
| } | |
| .header-icon { | |
| font-size: 48px; | |
| animation: scaleIn 0.8s ease-out; | |
| } | |
| .header-text h1 { | |
| margin: 0; | |
| font-size: 36px; | |
| font-weight: 800; | |
| color: white; | |
| letter-spacing: -0.5px; | |
| text-shadow: 0 2px 10px rgba(0,0,0,0.1); | |
| } | |
| .header-text .subtitle { | |
| margin: 8px 0 0; | |
| font-size: 16px; | |
| color: rgba(255, 255, 255, 0.9); | |
| font-weight: 500; | |
| } | |
| .header-stats { | |
| display: flex; | |
| gap: 24px; | |
| margin-top: 24px; | |
| } | |
| .header-stat { | |
| background: rgba(255, 255, 255, 0.15); | |
| backdrop-filter: blur(10px); | |
| padding: 16px 24px; | |
| border-radius: var(--radius-md); | |
| border: 1px solid rgba(255, 255, 255, 0.2); | |
| } | |
| .header-stat-label { | |
| font-size: 12px; | |
| color: rgba(255, 255, 255, 0.8); | |
| text-transform: uppercase; | |
| letter-spacing: 0.5px; | |
| font-weight: 600; | |
| } | |
| .header-stat-value { | |
| font-size: 24px; | |
| color: white; | |
| font-weight: 800; | |
| margin-top: 4px; | |
| } | |
| /* ========== THEME TOGGLE ========== */ | |
| #theme-toggle-btn { | |
| background: rgba(255, 255, 255, 0.2) !important; | |
| backdrop-filter: blur(10px); | |
| border: 1px solid rgba(255, 255, 255, 0.3) !important; | |
| border-radius: 50px !important; | |
| padding: 12px 24px !important; | |
| cursor: pointer; | |
| transition: all var(--transition-fast) !important; | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| } | |
| /* Light theme: black text */ | |
| :root #theme-toggle-btn, | |
| :root #theme-toggle-btn button { | |
| color: #0f172a !important; | |
| } | |
| /* Dark theme: white text */ | |
| [data-theme="dark"] #theme-toggle-btn, | |
| [data-theme="dark"] #theme-toggle-btn button { | |
| color: #ffffff !important; | |
| } | |
| #theme-toggle-btn:hover { | |
| background: rgba(255, 255, 255, 0.3) !important; | |
| transform: scale(1.05); | |
| } | |
| /* ========== CARDS & CONTAINERS ========== */ | |
| .input-card, .output-card { | |
| background: var(--bg-secondary); | |
| border: 1px solid var(--border-color); | |
| border-radius: var(--radius-lg); | |
| padding: 24px; | |
| box-shadow: var(--shadow-md); | |
| animation: fadeInUp 0.6s ease-out; | |
| } | |
| .section-title { | |
| font-size: 18px; | |
| font-weight: 700; | |
| color: var(--text-primary); | |
| margin-bottom: 20px; | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| } | |
| .section-title::before { | |
| content: ''; | |
| width: 4px; | |
| height: 24px; | |
| background: linear-gradient(180deg, var(--accent-primary), var(--accent-secondary)); | |
| border-radius: 2px; | |
| } | |
| /* ========== RESULT CARDS ========== */ | |
| .result-card { | |
| background: var(--bg-secondary); | |
| border: 1px solid var(--border-color); | |
| border-radius: var(--radius-xl); | |
| padding: 0; | |
| box-shadow: var(--shadow-lg); | |
| overflow: hidden; | |
| } | |
| .result-banner { | |
| padding: 24px 28px; | |
| display: flex; | |
| align-items: center; | |
| gap: 16px; | |
| position: relative; | |
| overflow: hidden; | |
| } | |
| .result-banner::before { | |
| content: ''; | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| right: 0; | |
| bottom: 0; | |
| opacity: 0.1; | |
| z-index: 0; | |
| } | |
| .result-banner.ok { | |
| background: linear-gradient(135deg, #10b981 0%, #059669 100%); | |
| } | |
| .result-banner.warn { | |
| background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); | |
| } | |
| .result-banner.neutral { | |
| background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%); | |
| } | |
| .banner-emoji { | |
| font-size: 48px; | |
| position: relative; | |
| z-index: 1; | |
| animation: scaleIn 0.6s ease-out; | |
| } | |
| .banner-text { | |
| flex: 1; | |
| position: relative; | |
| z-index: 1; | |
| } | |
| .banner-title { | |
| font-size: 24px; | |
| font-weight: 800; | |
| color: white; | |
| margin-bottom: 4px; | |
| } | |
| .banner-sub { | |
| font-size: 14px; | |
| color: rgba(255, 255, 255, 0.9); | |
| font-weight: 500; | |
| } | |
| .pulse-dot { | |
| width: 12px; | |
| height: 12px; | |
| background: white; | |
| border-radius: 50%; | |
| position: relative; | |
| z-index: 1; | |
| animation: pulse 2s ease-in-out infinite; | |
| } | |
| /* ========== METRICS GRID ========== */ | |
| .metrics-grid { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); | |
| gap: 16px; | |
| padding: 24px; | |
| } | |
| .metric-card { | |
| background: var(--bg-tertiary); | |
| border-radius: var(--radius-md); | |
| padding: 20px; | |
| text-align: center; | |
| border: 1px solid var(--border-color); | |
| } | |
| .metric-icon { | |
| font-size: 32px; | |
| margin-bottom: 12px; | |
| } | |
| .metric-label { | |
| font-size: 12px; | |
| color: var(--text-muted); | |
| text-transform: uppercase; | |
| letter-spacing: 0.5px; | |
| font-weight: 600; | |
| margin-bottom: 8px; | |
| } | |
| .metric-value { | |
| font-size: 18px; | |
| font-weight: 800; | |
| color: var(--text-primary); | |
| font-family: 'SF Mono', Monaco, 'Courier New', monospace; | |
| } | |
| .metric-value.small { | |
| font-size: 14px; | |
| } | |
| /* ========== PROBABILITY SECTION ========== */ | |
| .probability-section { | |
| margin: 24px; | |
| padding: 24px; | |
| background: var(--bg-tertiary); | |
| border-radius: var(--radius-lg); | |
| border: 1px solid var(--border-color); | |
| } | |
| .prob-header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| margin-bottom: 20px; | |
| } | |
| .prob-title { | |
| font-size: 16px; | |
| font-weight: 700; | |
| color: var(--text-primary); | |
| } | |
| .prob-value-large { | |
| font-size: 32px; | |
| font-weight: 800; | |
| background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary)); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| background-clip: text; | |
| } | |
| .prob-bar-container { | |
| margin-top: 16px; | |
| } | |
| .prob-bar-track { | |
| height: 16px; | |
| background: var(--bg-secondary); | |
| border-radius: 999px; | |
| overflow: hidden; | |
| border: 2px solid var(--border-color); | |
| position: relative; | |
| } | |
| .prob-bar-fill { | |
| height: 100%; | |
| border-radius: 999px; | |
| position: relative; | |
| overflow: hidden; | |
| transition: width 1s cubic-bezier(0.4, 0, 0.2, 1); | |
| } | |
| .prob-bar-fill.ok { | |
| background: linear-gradient(90deg, #10b981, #059669); | |
| } | |
| .prob-bar-fill.warn { | |
| background: linear-gradient(90deg, #f59e0b, #d97706); | |
| } | |
| .prob-bar-fill.neutral { | |
| background: linear-gradient(90deg, #3b82f6, #2563eb); | |
| } | |
| .shimmer { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| background: linear-gradient( | |
| 90deg, | |
| transparent, | |
| rgba(255, 255, 255, 0.3), | |
| transparent | |
| ); | |
| animation: shimmer 2s infinite; | |
| } | |
| .prob-markers { | |
| display: flex; | |
| justify-content: space-between; | |
| margin-top: 8px; | |
| font-size: 11px; | |
| color: var(--text-muted); | |
| font-weight: 600; | |
| } | |
| /* ========== DETAILS GRID ========== */ | |
| .details-grid { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); | |
| gap: 16px; | |
| padding: 0 24px 24px; | |
| } | |
| .detail-box { | |
| background: var(--bg-tertiary); | |
| border: 1px solid var(--border-color); | |
| border-radius: var(--radius-md); | |
| padding: 20px; | |
| display: flex; | |
| align-items: center; | |
| gap: 16px; | |
| } | |
| .detail-icon { | |
| width: 48px; | |
| height: 48px; | |
| border-radius: var(--radius-sm); | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| flex-shrink: 0; | |
| } | |
| .detail-icon.ok { | |
| background: linear-gradient(135deg, #10b981, #059669); | |
| color: white; | |
| } | |
| .detail-icon.warn { | |
| background: linear-gradient(135deg, #f59e0b, #d97706); | |
| color: white; | |
| } | |
| .detail-icon.neutral { | |
| background: linear-gradient(135deg, #3b82f6, #2563eb); | |
| color: white; | |
| } | |
| .detail-content { | |
| flex: 1; | |
| } | |
| .detail-label { | |
| font-size: 12px; | |
| color: var(--text-muted); | |
| text-transform: uppercase; | |
| letter-spacing: 0.5px; | |
| font-weight: 600; | |
| margin-bottom: 4px; | |
| } | |
| .detail-value { | |
| font-size: 20px; | |
| font-weight: 800; | |
| color: var(--text-primary); | |
| font-family: 'SF Mono', Monaco, 'Courier New', monospace; | |
| } | |
| /* ========== DISCLAIMER ========== */ | |
| .disclaimer { | |
| margin: 0 24px 24px; | |
| padding: 16px; | |
| background: rgba(59, 130, 246, 0.1); | |
| border: 1px solid rgba(59, 130, 246, 0.2); | |
| border-radius: var(--radius-md); | |
| display: flex; | |
| align-items: flex-start; | |
| gap: 12px; | |
| font-size: 13px; | |
| color: var(--text-secondary) !important; | |
| line-height: 1.5; | |
| } | |
| /* Force disclaimer text to follow theme */ | |
| [data-theme="dark"] .disclaimer, | |
| [data-theme="dark"] .disclaimer span { | |
| color: var(--text-secondary) !important; | |
| } | |
| /* Keep icon accent color (donβt override in dark mode) */ | |
| .disclaimer svg { | |
| color: var(--accent-primary) !important; | |
| flex-shrink: 0; | |
| margin-top: 2px; | |
| } | |
| /* ========== BATCH RESULTS ========== */ | |
| .batch-results { | |
| background: var(--bg-secondary); | |
| border: 1px solid var(--border-color); | |
| border-radius: var(--radius-xl); | |
| overflow: hidden; | |
| box-shadow: var(--shadow-lg); | |
| } | |
| .batch-header { | |
| background: linear-gradient(135deg, var(--accent-primary) 0%, var(--accent-secondary) 100%); | |
| padding: 24px 28px; | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| } | |
| .batch-title { | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| font-size: 24px; | |
| font-weight: 800; | |
| color: white; | |
| } | |
| .batch-icon { | |
| font-size: 32px; | |
| } | |
| .batch-count { | |
| background: rgba(255, 255, 255, 0.2); | |
| padding: 8px 16px; | |
| border-radius: 999px; | |
| font-size: 14px; | |
| color: white; | |
| font-weight: 600; | |
| border: 1px solid rgba(255, 255, 255, 0.3); | |
| } | |
| .stats-showcase { | |
| padding: 24px; | |
| } | |
| .stat-mega { | |
| background: var(--bg-tertiary); | |
| border: 1px solid var(--border-color); | |
| border-radius: var(--radius-lg); | |
| padding: 32px; | |
| margin-bottom: 20px; | |
| display: flex; | |
| align-items: center; | |
| gap: 24px; | |
| position: relative; | |
| overflow: hidden; | |
| } | |
| .stat-mega-icon { | |
| width: 64px; | |
| height: 64px; | |
| border-radius: var(--radius-md); | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-size: 32px; | |
| color: white; | |
| flex-shrink: 0; | |
| } | |
| .stat-mega-icon.ok { | |
| background: linear-gradient(135deg, #10b981, #059669); | |
| } | |
| .stat-mega-content { | |
| flex: 1; | |
| } | |
| .stat-mega-label { | |
| font-size: 14px; | |
| color: var(--text-muted); | |
| text-transform: uppercase; | |
| letter-spacing: 0.5px; | |
| font-weight: 600; | |
| margin-bottom: 8px; | |
| } | |
| .stat-mega-value { | |
| font-size: 48px; | |
| font-weight: 800; | |
| color: var(--text-primary); | |
| line-height: 1; | |
| margin-bottom: 8px; | |
| } | |
| .stat-mega-sub { | |
| font-size: 16px; | |
| color: var(--text-secondary); | |
| font-weight: 500; | |
| } | |
| .stat-ring { | |
| width: 100px; | |
| height: 100px; | |
| position: relative; | |
| } | |
| .stat-ring svg { | |
| transform: rotate(-90deg); | |
| width: 100%; | |
| height: 100%; | |
| } | |
| .ring-bg { | |
| fill: none; | |
| stroke: var(--border-color); | |
| stroke-width: 8; | |
| } | |
| .ring-fill { | |
| fill: none; | |
| stroke: url(#gradient); | |
| stroke-width: 8; | |
| stroke-linecap: round; | |
| stroke-dasharray: 283; | |
| transition: stroke-dashoffset 1s ease; | |
| } | |
| .stats-grid { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); | |
| gap: 16px; | |
| } | |
| .stat-card { | |
| background: var(--bg-tertiary); | |
| border: 1px solid var(--border-color); | |
| border-radius: var(--radius-md); | |
| padding: 20px; | |
| text-align: center; | |
| } | |
| .stat-icon { | |
| font-size: 32px; | |
| margin-bottom: 12px; | |
| } | |
| .stat-label { | |
| font-size: 12px; | |
| color: var(--text-muted); | |
| text-transform: uppercase; | |
| letter-spacing: 0.5px; | |
| font-weight: 600; | |
| margin-bottom: 8px; | |
| } | |
| .stat-value { | |
| font-size: 24px; | |
| font-weight: 800; | |
| color: var(--text-primary); | |
| font-family: 'SF Mono', Monaco, 'Courier New', monospace; | |
| } | |
| .stat-value.small { | |
| font-size: 16px; | |
| } | |
| .download-prompt { | |
| margin-top: 24px; | |
| padding: 20px; | |
| background: rgba(59, 130, 246, 0.1); | |
| border: 1px solid rgba(59, 130, 246, 0.2); | |
| border-radius: var(--radius-md); | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 12px; | |
| font-size: 14px; | |
| color: var(--text-primary); | |
| font-weight: 600; | |
| } | |
| .download-prompt svg { | |
| color: var(--accent-primary); | |
| } | |
| /* ========== AGE PREVIEW ========== */ | |
| .age-preview-box { | |
| margin-top: 12px; | |
| padding: 16px 20px; | |
| background: var(--bg-tertiary); | |
| border: 2px dashed var(--border-color); | |
| border-radius: var(--radius-md); | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| } | |
| .preview-icon { | |
| font-size: 24px; | |
| } | |
| .preview-content { | |
| flex: 1; | |
| } | |
| .preview-label { | |
| font-size: 12px; | |
| color: var(--text-muted); | |
| text-transform: uppercase; | |
| letter-spacing: 0.5px; | |
| font-weight: 600; | |
| margin-bottom: 4px; | |
| } | |
| .preview-value { | |
| font-size: 18px; | |
| font-weight: 800; | |
| color: var(--text-primary); | |
| font-family: 'SF Mono', Monaco, 'Courier New', monospace; | |
| } | |
| /* ========== BUTTONS ========== */ | |
| #predict-btn button, #batch-btn button { | |
| background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary)) !important; | |
| color: white !important; | |
| border: none !important; | |
| border-radius: var(--radius-md) !important; | |
| padding: 16px 32px !important; | |
| font-size: 16px !important; | |
| font-weight: 700 !important; | |
| box-shadow: var(--shadow-md) !important; | |
| transition: all var(--transition-fast) !important; | |
| } | |
| #predict-btn button:hover, #batch-btn button:hover { | |
| transform: translateY(-2px); | |
| box-shadow: var(--shadow-lg) !important; | |
| } | |
| /* ========== TABS ========== */ | |
| .tabs { | |
| border-bottom: 2px solid var(--border-color); | |
| margin-bottom: 24px; | |
| } | |
| .tab-nav button { | |
| background: transparent !important; | |
| border: none !important; | |
| border-bottom: 3px solid transparent !important; | |
| color: var(--text-secondary) !important; | |
| font-weight: 600 !important; | |
| padding: 12px 24px !important; | |
| transition: all var(--transition-fast) !important; | |
| } | |
| .tab-nav button.selected { | |
| color: var(--accent-primary) !important; | |
| border-bottom-color: var(--accent-primary) !important; | |
| } | |
| /* ========== INFO BOX ========== */ | |
| .info-box { | |
| background: rgba(139, 92, 246, 0.1); | |
| border: 1px solid rgba(139, 92, 246, 0.2); | |
| border-radius: var(--radius-md); | |
| padding: 16px 20px; | |
| margin-bottom: 20px; | |
| } | |
| .info-box strong { | |
| color: var(--accent-secondary); | |
| font-weight: 700; | |
| } | |
| /* Ensure info-box text follows theme */ | |
| .info-box, | |
| .info-box * { | |
| color: var(--text-secondary) !important; | |
| } | |
| /* Keep strong highlighted */ | |
| .info-box strong { | |
| color: var(--accent-secondary) !important; | |
| } | |
| /* ========== RESPONSIVE ========== */ | |
| @media (max-width: 768px) { | |
| .header-top { | |
| flex-direction: column; | |
| align-items: flex-start; | |
| gap: 16px; | |
| } | |
| .header-stats { | |
| flex-direction: column; | |
| width: 100%; | |
| } | |
| .header-stat { | |
| width: 100%; | |
| } | |
| .header-text h1 { | |
| font-size: 28px; | |
| } | |
| .metrics-grid { | |
| grid-template-columns: 1fr; | |
| } | |
| .details-grid { | |
| grid-template-columns: 1fr; | |
| } | |
| .stat-mega { | |
| flex-direction: column; | |
| text-align: center; | |
| } | |
| .stats-grid { | |
| grid-template-columns: 1fr; | |
| } | |
| .batch-header { | |
| flex-direction: column; | |
| align-items: flex-start; | |
| gap: 12px; | |
| } | |
| } | |
| /* ========== GRADIO OVERRIDES ========== */ | |
| .gr-button-primary { | |
| background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary)) !important; | |
| border: none !important; | |
| } | |
| .gr-input, .gr-box { | |
| border-color: var(--border-color) !important; | |
| background: var(--bg-secondary) !important; | |
| } | |
| .gr-form { | |
| background: var(--bg-secondary) !important; | |
| border-color: var(--border-color) !important; | |
| } | |
| .gr-panel { | |
| background: var(--bg-secondary) !important; | |
| border-color: var(--border-color) !important; | |
| } | |
| /* ============================== | |
| FORCE BUTTON TEXT COLORS | |
| ============================== */ | |
| #predict-btn button, | |
| #batch-btn button, | |
| #predict-btn button * , | |
| #batch-btn button * { | |
| color: #ffffff !important; | |
| } | |
| """ | |
| JS = r""" | |
| function() { | |
| const root = document.documentElement; | |
| const currentTheme = root.getAttribute('data-theme'); | |
| const newTheme = currentTheme === 'dark' ? 'light' : 'dark'; | |
| root.setAttribute('data-theme', newTheme); | |
| localStorage.setItem('theme', newTheme); | |
| const btn = document.querySelector('#theme-toggle-btn button'); | |
| if (btn) { | |
| btn.textContent = newTheme === 'dark' ? 'βοΈ Light Mode' : 'π Dark Mode'; | |
| } | |
| return newTheme; | |
| } | |
| """ | |
| INIT_JS = r""" | |
| function() { | |
| const savedTheme = localStorage.getItem('theme') || 'light'; | |
| document.documentElement.setAttribute('data-theme', savedTheme); | |
| const btn = document.querySelector('#theme-toggle-btn button'); | |
| if (btn) { | |
| btn.textContent = savedTheme === 'dark' ? 'βοΈ Light Mode' : 'π Dark Mode'; | |
| } | |
| } | |
| """ | |
| theme = gr.themes.Soft( | |
| primary_hue="blue", | |
| secondary_hue="purple", | |
| neutral_hue="slate", | |
| radius_size="lg", | |
| font=[gr.themes.GoogleFont("Inter"), "system-ui", "sans-serif"], | |
| ) | |
| with gr.Blocks(theme=theme, css=CSS, js=INIT_JS, title="CI Outcome Predictor") as demo: | |
| gr.HTML(f""" | |
| <div class="app-header"> | |
| <div class="header-content"> | |
| <div class="header-top"> | |
| <div class="header-title"> | |
| <div class="header-icon">π¬</div> | |
| <div class="header-text"> | |
| <h1>CI Outcome Predictor</h1> | |
| <div class="subtitle">Advanced ML-powered Cochlear Implant Success Analysis</div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="header-stats"> | |
| <div class="header-stat"> | |
| <div class="header-stat-label">Gene Options</div> | |
| <div class="header-stat-value">{len(gene_choices) if APP_READY else 0}</div> | |
| </div> | |
| <div class="header-stat"> | |
| <div class="header-stat-label">Model Features</div> | |
| <div class="header-stat-value">{len(input_cols) if APP_READY else 0}</div> | |
| </div> | |
| <div class="header-stat"> | |
| <div class="header-stat-label">Prediction Models</div> | |
| <div class="header-stat-value">2</div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| """) | |
| theme_state = gr.State("light") | |
| theme_btn = gr.Button("π Dark Mode", elem_id="theme-toggle-btn") | |
| theme_btn.click(fn=None, js=JS, outputs=theme_state) | |
| with gr.Tabs(): | |
| with gr.Tab("π― Single Prediction"): | |
| with gr.Row(): | |
| with gr.Column(scale=1): | |
| gr.HTML('<div class="section-title">π Input Parameters</div>') | |
| gene_in = gr.Dropdown( | |
| choices=gene_choices, | |
| value=gene_choices[0] if gene_choices else None, | |
| label="𧬠Select Gene", | |
| info="Choose the gene variant for analysis", | |
| filterable=True, | |
| ) | |
| age_in = gr.Textbox( | |
| label="π Patient Age", | |
| placeholder="Examples: 1.11 | 1.6YRS | 2.3", | |
| info="Enter age in supported format" | |
| ) | |
| parse_mode = gr.Radio( | |
| choices=[ | |
| "Decimal (1.11 = 1.11 years)", | |
| "Years.Months (1.11 = 1y 11m)" | |
| ], | |
| value="Decimal (1.11 = 1.11 years)", | |
| label="βοΈ Age Parsing Mode", | |
| info="Select how age should be interpreted" | |
| ) | |
| age_hint = gr.HTML(value=age_preview("", "Decimal (1.11 = 1.11 years)")) | |
| btn = gr.Button("π Run Prediction", elem_id="predict-btn", size="lg") | |
| with gr.Column(scale=1): | |
| gr.HTML('<div class="section-title">π Prediction Results</div>') | |
| single_out = gr.HTML( | |
| value=_missing_files_message() if not APP_READY else | |
| "<div style='text-align:center; padding:60px 20px; color:var(--text-muted);'>π Enter parameters and click 'Run Prediction' to see results</div>" | |
| ) | |
| age_in.change(fn=age_preview, inputs=[age_in, parse_mode], outputs=[age_hint]) | |
| parse_mode.change(fn=age_preview, inputs=[age_in, parse_mode], outputs=[age_hint]) | |
| btn.click(fn=predict_single, inputs=[gene_in, age_in, parse_mode], outputs=[single_out]) | |
| with gr.Tab("π Batch Prediction (CSV)"): | |
| gr.HTML('<div class="section-title">π Batch Processing</div>') | |
| gr.HTML(f""" | |
| <div class="info-box"> | |
| <strong>π Requirements:</strong> Your CSV must include <strong>Gene</strong> and <strong>Age</strong> columns. | |
| Additional features will be auto-filled. Model expects <strong>{len(input_cols) if APP_READY else 0}</strong> total feature columns. | |
| </div> | |
| """) | |
| parse_mode_b = gr.Radio( | |
| choices=[ | |
| "Decimal (1.11 = 1.11 years)", | |
| "Years.Months (1.11 = 1y 11m)" | |
| ], | |
| value="Decimal (1.11 = 1.11 years)", | |
| label="βοΈ Age Parsing Mode", | |
| info="How should ages be interpreted?" | |
| ) | |
| csv_in = gr.File(file_types=[".csv"], label="π€ Upload CSV File", file_count="single") | |
| run_b = gr.Button("π Run Batch Prediction", elem_id="batch-btn", size="lg") | |
| batch_summary = gr.HTML(value="") | |
| gr.HTML('<div class="section-title" style="margin-top:24px;">π Preview (First 20 Rows)</div>') | |
| batch_preview = gr.Dataframe(headers=None, interactive=False, wrap=True) | |
| gr.HTML('<div class="section-title" style="margin-top:24px;">πΎ Download Results</div>') | |
| batch_file = gr.File(label="π₯ Download Full Results CSV") | |
| run_b.click(fn=predict_batch, inputs=[csv_in, parse_mode_b], outputs=[batch_summary, batch_preview, batch_file]) | |
| if __name__ == "__main__": | |
| # HF Spaces-safe launch (no share=True) | |
| port = int(os.getenv("PORT", "7860")) | |
| demo.launch(server_name="0.0.0.0", server_port=port, show_error=False) | |