Spaces:
Sleeping
Sleeping
| import os | |
| import warnings | |
| import gradio as gr | |
| import numpy as np | |
| import plotly.graph_objects as go | |
| from plotly.subplots import make_subplots | |
| import scanpy as sc | |
| warnings.filterwarnings('ignore') | |
| sc.settings.verbosity = 0 | |
| DATASET_REPO = "minuttilab/cDCFUN-data" | |
| _DIR = os.path.dirname(__file__) | |
| # ── Load data ───────────────────────────────────────────────────────────────── | |
| _LOCAL_H5AD = os.path.join(_DIR, "adata_annotated.h5ad") | |
| if os.path.exists(_LOCAL_H5AD): | |
| print("Loading local h5ad…") | |
| _data_path = _LOCAL_H5AD | |
| else: | |
| print("Fetching dataset from HuggingFace…") | |
| from huggingface_hub import hf_hub_download | |
| _data_path = hf_hub_download( | |
| repo_id=DATASET_REPO, | |
| filename="adata_annotated.h5ad", | |
| repo_type="dataset", | |
| token=os.environ.get("cDCFUN"), | |
| ) | |
| print("Loading AnnData…") | |
| adata = sc.read_h5ad(_data_path) | |
| print(f"Ready — {adata.n_obs:,} cells · {adata.n_vars:,} genes.") | |
| # ── Gene name lookup (case-insensitive) ─────────────────────────────────────── | |
| _var_lower = {g.lower(): g for g in adata.var_names} | |
| # ── UMAP coordinates ────────────────────────────────────────────────────────── | |
| if 'X_umap' in adata.obsm: | |
| _umap = np.asarray(adata.obsm['X_umap']) | |
| else: | |
| raise RuntimeError("AnnData object does not contain UMAP coordinates (adata.obsm['X_umap']).") | |
| def _get_expr(gene: str) -> np.ndarray: | |
| try: | |
| sub = adata[:, gene].X | |
| if hasattr(sub, 'toarray'): | |
| sub = sub.toarray() | |
| return np.asarray(sub).ravel() | |
| except Exception: | |
| return np.zeros(adata.n_obs, dtype=float) | |
| # ── Layout constants ────────────────────────────────────────────────────────── | |
| _MODEBAR_REMOVE = [ | |
| 'select2d', 'lasso2d', 'zoomIn2d', 'zoomOut2d', | |
| 'toggleSpikelines', 'hoverClosestCartesian', 'hoverCompareCartesian', | |
| ] | |
| CELL_SIZE = 260 | |
| CB_PAD = 60 | |
| _FONT = dict(family='Roboto, sans-serif', size=11) | |
| UMAP_STYLE = { | |
| 'expr': { | |
| 'size': 2, | |
| 'opacity': 0.85, | |
| 'colorscale': 'Spectral', | |
| 'reversescale': True, | |
| } | |
| } | |
| # ── Gene expression grid ────────────────────────────────────────────────────── | |
| def _make_expr_grid(genes: list, shared_scale: bool = False, n_cols: int = 3, size: int = None, opacity: float = None, colorscale: str = None, reversescale: bool = None) -> go.Figure: | |
| if not genes: | |
| return go.Figure() | |
| if size is None: size = UMAP_STYLE['expr']['size'] | |
| if opacity is None: opacity = UMAP_STYLE['expr']['opacity'] | |
| if colorscale is None: colorscale = UMAP_STYLE['expr']['colorscale'] | |
| if reversescale is None: reversescale = UMAP_STYLE['expr']['reversescale'] | |
| n_rows = (len(genes) + n_cols - 1) // n_cols | |
| H_GAP = 0.04 | |
| GAP_PX = 30 | |
| MARGIN_T = 30 | |
| MARGIN_B = 5 | |
| paper_h = n_rows * CELL_SIZE + max(0, n_rows - 1) * GAP_PX | |
| total_h = paper_h + MARGIN_T + MARGIN_B | |
| V_GAP = (GAP_PX / paper_h) if n_rows > 1 else 0.0 | |
| subplot_w = (1.0 - H_GAP * (n_cols - 1)) / n_cols | |
| subplot_h = CELL_SIZE / paper_h | |
| all_vals = [_get_expr(g) for g in genes] | |
| global_max = float(max(v.max() for v in all_vals)) if shared_scale else None | |
| subtitles = genes + [''] * (n_rows * n_cols - len(genes)) | |
| fig = make_subplots( | |
| rows=n_rows, cols=n_cols, | |
| subplot_titles=subtitles, | |
| horizontal_spacing=H_GAP, | |
| vertical_spacing=V_GAP, | |
| ) | |
| for i, (gene, vals) in enumerate(zip(genes, all_vals)): | |
| r, c = divmod(i, n_cols) | |
| vmin = 0.0 if shared_scale else float(vals.min()) | |
| vmax = global_max if shared_scale else float(vals.max()) | |
| cb_x = c * (subplot_w + H_GAP) + subplot_w + 0.01 | |
| cb_y = 1.0 - r * (subplot_h + V_GAP) - subplot_h / 2 | |
| fig.add_trace(go.Scattergl( | |
| x=_umap[:, 0], y=_umap[:, 1], | |
| mode='markers', | |
| marker=dict( | |
| color=vals, | |
| colorscale=colorscale, | |
| reversescale=reversescale, | |
| cmin=vmin, cmax=vmax, | |
| size=size, | |
| opacity=opacity, | |
| colorbar=dict( | |
| x=cb_x, xanchor='left', | |
| y=cb_y, yanchor='middle', | |
| len=subplot_h, | |
| thickness=10, | |
| outlinewidth=0, | |
| tickfont=dict(size=9), | |
| nticks=4, | |
| ), | |
| ), | |
| hoverinfo='skip', | |
| showlegend=False, | |
| ), row=r + 1, col=c + 1) | |
| fig.update_xaxes(visible=False, row=r + 1, col=c + 1) | |
| fig.update_yaxes(visible=False, row=r + 1, col=c + 1) | |
| fig.update_layout( | |
| height=total_h, | |
| margin=dict(l=5, r=CB_PAD, t=MARGIN_T, b=MARGIN_B), | |
| plot_bgcolor='rgba(0,0,0,0)', | |
| paper_bgcolor='rgba(0,0,0,0)', | |
| dragmode='pan', | |
| font=_FONT, | |
| modebar_remove=_MODEBAR_REMOVE, | |
| ) | |
| return fig | |
| # ── UI ──────────────────────────────────────────────────────────────────────── | |
| INSTRUCTIONS = """ | |
| Type gene name(s) separated by commas — e.g. `Clec7a, Cd274, Ido1` | |
| Press **Enter** or click **Plot genes**. | |
| """ | |
| CSS = """ | |
| * { font-family: Roboto, sans-serif !important; } | |
| footer, header { display: none !important; } | |
| .gradio-container { max-width: 100% !important; padding: 8px !important; } | |
| @media (prefers-color-scheme: light) { | |
| .js-plotly-plot text { fill: #1e293b !important; } | |
| } | |
| @media (prefers-color-scheme: dark) { | |
| .js-plotly-plot text { fill: #e2e8f0 !important; } | |
| } | |
| .js-plotly-plot { width: 100% !important; } | |
| .gradio-container .plotly-graph-div { width: 100% !important; overflow: visible !important; } | |
| .js-plotly-plot svg { max-width: 100% !important; } | |
| .gradio-container .plot-container { overflow: visible !important; height: auto !important; } | |
| /* Responsive expression grid: desktop shows 3-col, mobile shows 1-col */ | |
| #expr-desktop { display: block; } | |
| #expr-mobile { display: none; } | |
| @media (max-width: 768px) { | |
| #expr-desktop { display: none !important; } | |
| #expr-mobile { display: block !important; } | |
| } | |
| .js-plotly-plot .modebar-container { | |
| right: auto !important; | |
| left: -25px !important; | |
| top: 0px !important; | |
| width: auto !important; | |
| } | |
| .js-plotly-plot .modebar { | |
| left: 28px !important; | |
| right: auto !important; | |
| transform: none !important; | |
| display: flex !important; | |
| flex-direction: row !important; | |
| flex-wrap: wrap !important; | |
| width: 86px !important; | |
| justify-content: flex-start !important; | |
| } | |
| .js-plotly-plot .modebar-btn { | |
| opacity: 1 !important; | |
| display: flex !important; | |
| align-items: center !important; | |
| justify-content: center !important; | |
| width: 28px !important; | |
| height: 28px !important; | |
| background: transparent !important; | |
| margin: 0 !important; | |
| padding: 0 !important; | |
| } | |
| .js-plotly-plot .modebar-btn svg { | |
| width: 16px !important; | |
| height: 16px !important; | |
| overflow: visible !important; | |
| } | |
| .js-plotly-plot .modebar-btn svg path, | |
| .js-plotly-plot .modebar-btn svg rect, | |
| .js-plotly-plot .modebar-btn svg polygon { | |
| fill: #888888 !important; | |
| stroke: none !important; | |
| } | |
| .js-plotly-plot .modebar-btn:hover svg path, | |
| .js-plotly-plot .modebar-btn:hover svg rect, | |
| .js-plotly-plot .modebar-btn:hover svg polygon { | |
| fill: #2196F3 !important; | |
| } | |
| .js-plotly-plot .modebar-group { | |
| display: contents !important; | |
| } | |
| .js-plotly-plot .modebar-btn { order: 10 !important; } | |
| .js-plotly-plot .modebar-btn[data-title*="Plotly"] { order: 1 !important; } | |
| .js-plotly-plot .modebar-btn[data-title="Reset axes"] { order: 2 !important; } | |
| .js-plotly-plot .modebar-btn[data-title="Autoscale"] { order: 3 !important; } | |
| .js-plotly-plot .modebar-btn[data-title="Pan"] { order: 4 !important; } | |
| .js-plotly-plot .modebar-btn[data-title="Zoom"] { order: 5 !important; } | |
| .js-plotly-plot .modebar-btn[data-title*="Download"] { order: 6 !important; } | |
| .js-plotly-plot .modebar-btn[data-title]::before { display: none !important; } | |
| .js-plotly-plot .modebar-btn[data-title]::after { | |
| right: auto !important; | |
| left: 0px !important; | |
| margin-right: 0 !important; | |
| text-align: left !important; | |
| } | |
| .js-plotly-plot .modebar-btn[data-title="Zoom"]::after { content: "Draw Box to Zoom" !important; } | |
| .js-plotly-plot .modebar-btn[data-title="Pan"]::after { content: "Drag to Pan" !important; } | |
| .js-plotly-plot .modebar-btn[data-title="Autoscale"]::after { content: "Auto Scale" !important; } | |
| .js-plotly-plot .modebar-btn[data-title="Reset axes"]::after { content: "Reset View" !important; } | |
| .js-plotly-plot .modebar-btn[data-title*="Download"]::after { content: "Save as PNG" !important; } | |
| .js-plotly-plot .modebar-btn[data-title*="Plotly"]::after { content: "About Plotly" !important; } | |
| """ | |
| with gr.Blocks(theme=gr.themes.Soft(primary_hue="indigo"), title="Gene Expression Explorer", css=CSS, fill_width=True) as demo: | |
| with gr.Row(): | |
| with gr.Column(scale=1): | |
| gr.Markdown("# Gene Expression Explorer") | |
| gr.Markdown("Explore gene expression across dendritic cell populations. Interactive Plotly UMAPs are shown.") | |
| gene_box = gr.Textbox(placeholder="e.g. Clec7a, Cd274, Ido1, Mafb", label="Gene names (comma-separated)", lines=1) | |
| with gr.Row(): | |
| plot_btn = gr.Button("Plot genes", variant="primary") | |
| shared_chk = gr.Checkbox(label="Shared scale (0 → max)", value=False) | |
| error_md = gr.Markdown("") | |
| gr.Markdown(INSTRUCTIONS) | |
| gr.Markdown("**Reference UMAPs**") | |
| gr.Markdown("Cell type") | |
| gr.Image(value=os.path.join(_DIR, "ref_celltype.png"), show_label=False, | |
| interactive=False, show_download_button=False, show_fullscreen_button=False, | |
| height=CELL_SIZE) | |
| gr.Markdown("Lineage") | |
| gr.Image(value=os.path.join(_DIR, "ref_lineage.png"), show_label=False, | |
| interactive=False, show_download_button=False, show_fullscreen_button=False, | |
| height=CELL_SIZE) | |
| gr.Markdown("Condition") | |
| gr.Image(value=os.path.join(_DIR, "ref_condition.png"), show_label=False, | |
| interactive=False, show_download_button=False, show_fullscreen_button=False, | |
| height=CELL_SIZE) | |
| with gr.Column(scale=3): | |
| gr.Markdown("**Gene expression**") | |
| out_plot_desktop = gr.Plot(show_label=False, elem_id="expr-desktop") | |
| out_plot_mobile = gr.Plot(show_label=False, elem_id="expr-mobile") | |
| def plot_genes_interactive(gene_input: str, shared_scale: bool): | |
| if not gene_input or not gene_input.strip(): | |
| return go.Figure(), go.Figure(), "" | |
| raw = [g.strip() for g in gene_input.replace(';', ',').split(',') if g.strip()] | |
| found = [_var_lower[g.lower()] for g in raw if g.lower() in _var_lower] | |
| missed = [g for g in raw if g.lower() not in _var_lower] | |
| msg = "" | |
| if missed: | |
| msg = f"⚠️ Not found in dataset: {', '.join(missed)}" | |
| if not found: | |
| return go.Figure(), go.Figure(), msg or "No valid gene names entered." | |
| fig_desktop = _make_expr_grid(found, shared_scale=shared_scale, n_cols=3, **UMAP_STYLE['expr']) | |
| fig_mobile = _make_expr_grid(found, shared_scale=shared_scale, n_cols=1, **UMAP_STYLE['expr']) | |
| plotted = f"**Plotting:** {', '.join(found)}" | |
| msg = plotted + ("\n\n" + msg if msg else "") | |
| return fig_desktop, fig_mobile, msg | |
| plot_btn.click(plot_genes_interactive, inputs=[gene_box, shared_chk], outputs=[out_plot_desktop, out_plot_mobile, error_md], api_name="plot") | |
| gene_box.submit(plot_genes_interactive, inputs=[gene_box, shared_chk], outputs=[out_plot_desktop, out_plot_mobile, error_md]) | |
| demo.queue() | |
| demo.launch() | |