Spaces:
Running on Zero
Running on Zero
File size: 18,084 Bytes
e794567 d6e8796 e794567 a657a30 e794567 a46a831 a657a30 e794567 485fb8a e794567 9494da9 e794567 9494da9 e794567 9494da9 e794567 a657a30 e794567 815c5e5 0211f62 815c5e5 0211f62 815c5e5 e794567 09d5e2c e794567 815c5e5 e794567 6ef271b e794567 d6e8796 e794567 2a0a28d 3642b56 e794567 09d5e2c e3b797d 09d5e2c e794567 815c5e5 0211f62 bebb3a0 815c5e5 e794567 09d5e2c e794567 a657a30 f8138ed a657a30 f8138ed a657a30 f8138ed 19e0b4f a657a30 f8138ed a657a30 19e0b4f 59ab251 a657a30 f8138ed 59ab251 a657a30 bebb3a0 69a3abb bebb3a0 6ef271b 7bc588d 69a3abb 6ef271b a657a30 6ef271b 69a3abb 6ef271b 7fb4074 e794567 5e7868c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 | """CELL-FM CondenSeq demo: protein sequence -> condensate titration curve.
Enter an IDP sequence; the app generates a concentration ladder of images with
CELL-FM, classifies each as condensed or diffuse, smooths the calls into a
titration curve, and integrates it into AUC (condensation propensity) and AAC
(reentrant dissolution).
"""
import os
import shutil
import tempfile
import gradio as gr
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from cell_fm.apps.condenseq import metrics, pipeline
from cell_fm.apps.virtual_opencell import viewer
# gradio_client walks component API schemas to build /gradio_api/info. JSON Schema
# allows `additionalProperties: true`, but the walker recurses into that bool as if
# it were a schema and then does `"const" in schema`, raising
# TypeError: argument of type 'bool' is not iterable
# which 500s /gradio_api/info -- the API panel and any gradio_client call -- while
# the UI itself keeps serving. Short-circuit bool schemas before they reach get_type().
# The recursive calls resolve this name through the module global, so rebinding it
# covers the nested case that actually triggers here.
try:
import gradio_client.utils as _gc_utils
_orig_schema_to_type = _gc_utils._json_schema_to_python_type
def _schema_to_type(schema, defs=None):
if isinstance(schema, bool):
return "Any" if schema else "None"
return _orig_schema_to_type(schema, defs)
_gc_utils._json_schema_to_python_type = _schema_to_type
except Exception as exc: # never let the shim take the app down
print(f"[startup] gradio_client schema patch skipped: {exc}")
# ZeroGPU: only present on HF Spaces hardware, so the import is optional.
try:
import spaces
GPU_DECORATOR = spaces.GPU(duration=int(os.environ.get("ZEROGPU_DURATION", "300")))
except Exception: # running locally or on a dedicated GPU
def GPU_DECORATOR(fn):
return fn
HERE = os.path.dirname(os.path.abspath(__file__))
# palette: categorical slots 1 and 2 (validated, CVD dE 24.7 / normal dE 33.6)
CURVE = "#2a78d6" # the titration curve and the area under it
GAP = "#eb6834" # the area above the curve
INK = "#0b0b0b"
MUTED = "#52514e"
SURFACE = "#fcfcfb"
def load_default_sequence() -> str:
"""The NUP98 IDP, wild type -- what the box is pre-filled with."""
df = pd.read_csv(os.path.join(HERE, "assets", "nup98_mutation_seqs.csv"), dtype=str)
return df.loc[df.mutation == "WT", "sequence"].iloc[0]
DEFAULT_SEQUENCE = load_default_sequence()
# The proteins with virtual staining, and OpenCell's annotations of their real cell lines
PROTEINS = viewer.load_catalog(os.path.join(HERE, "assets", "virtual_opencell_proteins.csv"))
def plot_curve(result: pipeline.Result):
"""The titration curve, with the two integrated areas drawn as the areas they are."""
x, y, curve = result.intensities, result.predictions, result.curve
flat = metrics.no_reentrant_curve(curve)
fig, ax = plt.subplots(figsize=(8.5, 4.6), facecolor=SURFACE)
ax.set_facecolor(SURFACE)
# the raw per-image calls, kept visible but recessive: the curve is a summary of these
ax.scatter(x, y, s=7, alpha=0.16, color=MUTED, linewidths=0, zorder=1)
# AUC: what the sequence actually does
ax.fill_between(x, 0, curve, color=CURVE, alpha=0.18, linewidth=0, zorder=2,
label=f"AUC {result.auc:.3f}")
# AAC: the condensation lost to reentrant dissolution at high concentration
if result.aac > 1e-9:
ax.fill_between(x, curve, flat, where=flat > curve, color=GAP, alpha=0.30,
linewidth=0, zorder=3, label=f"AAC {result.aac:.3f}")
ax.plot(x, flat, color=GAP, linewidth=1.2, linestyle="--", alpha=0.8, zorder=4)
ax.plot(x, curve, color=CURVE, linewidth=2.0, zorder=5)
ax.set_xscale("log")
ax.set_xlim(x.min(), x.max())
ax.set_ylim(-0.02, 1.02)
ax.set_xlabel("Protein intensity level (a.u., log scale)", fontsize=11, color=INK)
ax.set_ylabel("Condensate probability", fontsize=11, color=INK)
ax.tick_params(labelsize=10, colors=MUTED)
ax.grid(True, alpha=0.18, linewidth=0.8)
ax.set_axisbelow(True)
for side in ("top", "right"):
ax.spines[side].set_visible(False)
for side in ("left", "bottom"):
ax.spines[side].set_color(MUTED)
ax.spines[side].set_linewidth(0.8)
if result.c_sat != float("inf"):
ax.axvline(result.c_sat, color=MUTED, linewidth=1.0, linestyle=":", zorder=4)
ax.annotate(
f"$c_{{sat}}$ {result.c_sat:.0f}",
xy=(result.c_sat, 1.0), xytext=(6, -13), textcoords="offset points",
fontsize=9, color=MUTED, ha="left", va="top",
)
ax.legend(loc="upper left", frameon=False, fontsize=10, labelcolor=INK)
fig.tight_layout()
return fig
def sample_gallery(result: pipeline.Result):
"""Every generated image along the ladder, captioned with its protein expression level, so the
curve is checkable by eye."""
magma = matplotlib.colormaps["magma"]
return [
((magma(np.clip(img, 0, 1))[..., :3] * 255).astype(np.uint8), f"{level:.0f}")
for img, level in zip(result.images, result.image_intensities)
]
def summary_markdown(result: pipeline.Result) -> str:
return f"""
| | |
|---|---|
| **AUC** — area under the curve, condensation propensity | **{result.auc:.4f}** |
| **AAC** — area above the curve, reentrant dissolution | **{result.aac:.4f}** |
| Sequence length | {len(result.sequence)} aa |
Both areas are normalised by the width of the log-intensity range, so they lie in
[0, 1] and are comparable across sequences.
"""
def results_table(result: pipeline.Result) -> pd.DataFrame:
"""The numbers behind the plot, so the chart is never the only way to read them."""
return pd.DataFrame({
"protein_intensity_level": np.round(result.intensities, 4),
"predicted_class": result.predictions,
"condensate_probability_ma": np.round(result.curve, 6),
})
@GPU_DECORATOR
def analyse(sequence, progress=gr.Progress()):
try:
sequence = pipeline.clean_sequence(sequence)
except ValueError as exc:
raise gr.Error(str(exc)) from exc
result = pipeline.run(sequence, progress=progress)
table = results_table(result)
csv_path = os.path.join("/tmp", "pred_vs_intensity.csv")
table.to_csv(csv_path, index=False)
return plot_curve(result), sample_gallery(result), summary_markdown(result), table, csv_path
# ---------------------------------------------------------------------------
# Sections
#
# Each application of CELL-FM is one tab. To add another, write a
# build_<name>_section() that lays out its own components and wires its own
# events, then give it a gr.Tab in the Blocks at the bottom. Sections are
# independent: they share the loaded models via pipeline.py and nothing else.
# ---------------------------------------------------------------------------
def build_condensate_titration_section():
"""Sequence in, condensate titration curve out, summarised as AUC and AAC."""
gr.Markdown(
"""
Predict how an Intrinsically Disordered Peptide (IDP) behaves as its concentration rises.
"""
)
gr.Image(
value=os.path.join(HERE, "images", "cellfm_flow_diagram.png"),
show_label=False, show_download_button=False, container=False,
interactive=False,
)
with gr.Row():
with gr.Column(scale=2):
sequence = gr.Textbox(
value=DEFAULT_SEQUENCE, lines=4,
label="IDP sequence",
info=(
f"Exactly {pipeline.SEQUENCE_LENGTH} single-letter amino acids — the whole "
"CondenSeq library is 66-mers. FASTA headers are stripped."
),
)
run_button = gr.Button("Run analysis", variant="primary")
with gr.Column(scale=3):
summary = gr.Markdown()
curve_plot = gr.Plot(label="Condensate titration curve")
sample_plot = gr.Gallery(
label="Generated images (protein channel), captioned with the protein expression level (a.u.)",
columns=8, height=520, allow_preview=True, object_fit="contain", elem_classes="cs-gallery",
)
with gr.Accordion("Per-image results", open=False):
table = gr.Dataframe(label="pred_vs_intensity", wrap=True)
download = gr.File(label="Download CSV")
gr.Markdown(
"""
---
Generation follows `evaluate_seq2img_dict.py`, classification `evaluate_single_img.py`,
smoothing `ma_plot.py`, and the areas `analysis/ana_all_mutation_log_scale.py` from the
CELL-FM repository. Every image is conditioned on one fixed reference nucleus, so curves
are comparable across sequences.
"""
)
run_button.click(
analyse,
inputs=sequence,
outputs=[curve_plot, sample_plot, summary, table, download],
)
def build_virtual_opencell_section():
"""Pick an OpenCell protein and browse CELL-FM's virtual staining of it, laid out like an
OpenCell target page: the real cell line's annotations on the left, the images on the right."""
def load(gene):
row = PROTEINS.loc[gene]
stack, paths = viewer.load_samples(gene, int(row["n_samples"]))
return row, stack, paths
def show(gene, index, channel, n_min, n_max, n_gamma, t_min, t_max, t_gamma):
_, stack, _ = load(gene)
return viewer.render(stack[index], channel, (n_min, n_max, n_gamma), (t_min, t_max, t_gamma))
def sample_file(gene, index):
"""The raw sample under a readable name, copied where Gradio is allowed to serve it."""
_, _, paths = load(gene)
path = os.path.join(tempfile.gettempdir(), f"virtual_opencell_{gene}_{index + 1:04d}.tif")
shutil.copyfile(paths[index], path)
return path
def protein_view(gene, *settings):
"""Panel, thumbnails, first sample and its file for one protein."""
row, stack, _ = load(gene)
image = show(gene, 0, *settings)
return viewer.target_html(gene, row), viewer.thumbnails(stack), image, sample_file(gene, 0)
def umap_plot(gene):
try:
return viewer.umap_figure(gene)
except Exception as exc: # the map is an extra; the images work without it
print(f"[virtual opencell] map unavailable: {exc}")
return None
def select_protein(gene, channel, n_min, n_max, n_gamma, t_min, t_max, t_gamma):
try:
info, thumbs, image, path = protein_view(gene, channel, n_min, n_max, n_gamma, t_min, t_max, t_gamma)
except Exception as exc:
raise gr.Error(f"Could not load the images for {gene}: {viewer.load_error(exc)}") from exc
return info, gr.Gallery(value=thumbs, selected_index=0), image, 0, path, umap_plot(gene)
def select_sample(gene, channel, n_min, n_max, n_gamma, t_min, t_max, t_gamma, evt: gr.SelectData):
image = show(gene, evt.index, channel, n_min, n_max, n_gamma, t_min, t_max, t_gamma)
return image, evt.index, sample_file(gene, evt.index)
# Shown on first load: the default protein, unless its images cannot be fetched
defaults = ("Both", 0, 100, 1.0, 0, 100, 1.0)
try:
info0, thumbs0, image0, file0 = protein_view(viewer.DEFAULT_GENE, *defaults)
except Exception as exc: # e.g. no access to the dataset; the error is shown in the panel
print(f"[startup] virtual OpenCell preload failed: {exc}")
info0, thumbs0, image0, file0 = viewer.error_html(viewer.DEFAULT_GENE, exc), None, None, None
umap0 = umap_plot(viewer.DEFAULT_GENE)
try:
full_map0 = viewer.umap_figure("", full=True)
except Exception as exc: # the map is an extra; the images work without it
print(f"[virtual opencell] full map unavailable: {exc}")
full_map0 = None
with gr.Column(elem_id="virtual-opencell"):
with gr.Row(elem_classes="vo-navbar", equal_height=True):
gr.HTML(viewer.NAVBAR_HTML)
protein = gr.Dropdown(
choices=list(PROTEINS.index), value=viewer.DEFAULT_GENE, filterable=True,
show_label=False, container=False, scale=0, min_width=220, elem_classes="vo-search",
)
with gr.Row():
with gr.Column(scale=4):
info = gr.HTML(info0)
gr.HTML(viewer.UMAP_HTML, padding=False)
umap = gr.Plot(value=umap0, show_label=False, elem_classes="vo-umap")
with gr.Column(scale=6):
with gr.Row():
channel = gr.Radio(viewer.CHANNELS, value=defaults[0], label="Channel",
elem_classes="vo-buttons")
download = gr.DownloadButton("Download TIFF", value=file0, size="sm", variant="secondary",
scale=0, min_width=140, elem_classes="vo-download")
image = gr.Image(
value=image0, show_label=False, interactive=False, height=600,
show_download_button=True, elem_classes="vo-viewer",
)
gr.HTML('<div class="vo-settings-label">Samples</div>', padding=False)
samples = gr.Gallery(
value=thumbs0, selected_index=0 if thumbs0 else None, show_label=False,
columns=8, height=230, allow_preview=False, object_fit="contain",
show_share_button=False, show_download_button=False, elem_classes="vo-thumbnails",
)
with gr.Row(elem_classes="vo-settings"):
with gr.Column():
gr.HTML('<div class="vo-settings-label">Nucleus image settings</div>', padding=False)
n_min = gr.Slider(0, 100, value=defaults[1], step=1, label="Intensity min (%)")
n_max = gr.Slider(1, 150, value=defaults[2], step=1, label="Intensity max (%)")
n_gamma = gr.Slider(0.5, 1.5, value=defaults[3], step=0.05, label="Gamma")
with gr.Column():
gr.HTML('<div class="vo-settings-label">Target image settings</div>', padding=False)
t_min = gr.Slider(0, 100, value=defaults[4], step=1, label="Intensity min (%)")
t_max = gr.Slider(1, 150, value=defaults[5], step=1, label="Intensity max (%)")
t_gamma = gr.Slider(0.5, 1.5, value=defaults[6], step=0.05, label="Gamma")
# the full map: every image and every protein named. One fixed figure (~2 MB) sent with the
# page; the panel map above follows the selection, so this one never has to be re-sent
gr.HTML('<div class="vo-target"><div class="vo-section-header">Full virtual staining map</div>'
'<div class="vo-section-caption">All 93,510 generated images, each protein named at the '
'centre of its images; zoom in to separate the names</div></div>', padding=False)
gr.Plot(value=full_map0, show_label=False, elem_classes="vo-umap-full")
gr.HTML(viewer.FOOTER_HTML)
index = gr.State(0)
settings = [channel, n_min, n_max, n_gamma, t_min, t_max, t_gamma]
protein.change(select_protein, inputs=[protein] + settings,
outputs=[info, samples, image, index, download, umap])
# Redraws take milliseconds: no progress overlay, and a dragged slider only renders its latest value
samples.select(select_sample, inputs=[protein] + settings, outputs=[image, index, download],
show_progress="hidden")
for control in settings:
control.change(show, inputs=[protein, index] + settings, outputs=image,
show_progress="hidden", trigger_mode="always_last")
# Gradio's gallery block clips at its height; make the grid scroll inside it instead
CSS = viewer.CSS + """
.cs-gallery .gallery-container { height: 100%; }
.cs-gallery .grid-wrap { height: 100%; overflow-y: auto; }
"""
CITATION = """@article{zheng2026virtual,
title = {Virtual experiments bridge sequence and microscopy with generative models},
author = {Zheng, Dihan and Hong, Kibeom and Huang, Bo},
journal = {bioRxiv},
year = {2026},
doi = {10.64898/2026.09.13.751243},
url = {https://www.biorxiv.org/content/10.64898/2026.09.13.751243v1}
}"""
with gr.Blocks(title="CELL-FM", theme=gr.themes.Soft(), css=CSS, head=viewer.HEAD) as demo:
gr.Markdown(
"""
# CELL-FM
A virtual microscopy model that bridges microscopy images and protein sequences.
Paper: Zheng, Hong & Huang, *Virtual experiments bridge sequence and microscopy with
generative models*, bioRxiv (2026), [doi:10.64898/2026.09.13.751243](https://www.biorxiv.org/content/10.64898/2026.09.13.751243v1)
"""
)
with gr.Tabs():
with gr.Tab("Condensate Titration"):
build_condensate_titration_section()
with gr.Tab("Virtual OpenCell"):
build_virtual_opencell_section()
with gr.Accordion("Citation", open=False):
gr.Markdown(f"If you use CELL-FM, please cite:\n\n```bibtex\n{CITATION}\n```")
# Download weights and build the models at startup, on CPU. Under ZeroGPU the GPU
# budget only covers the decorated call, so this must not happen inside it.
try:
pipeline.load_models()
except Exception as exc: # surfaced in the Space logs; the UI still loads
print(f"[startup] model preload failed: {exc}")
if __name__ == "__main__":
# Only pass share when explicitly asked (run_local.sh). On Spaces the kwarg must
# be omitted entirely: an explicit share=False makes Gradio demand a share link
# when it cannot reach localhost, which kills the app at startup.
launch_kwargs = {"share": True} if os.environ.get("GRADIO_SHARE") == "1" else {}
demo.queue().launch(**launch_kwargs)
|