Spaces:
Running on Zero
Running on Zero
Condensate Titration: Virtual OpenCell layout and style; GPU only generates and classifies
Browse files- README.md +23 -4
- app.py +159 -189
- cell_fm/apps/condenseq/page.py +348 -0
- cell_fm/apps/condenseq/substitution.py +1 -1
- cell_fm/apps/virtual_opencell/viewer.py +6 -0
README.md
CHANGED
|
@@ -62,12 +62,31 @@ and are directly comparable between sequences.
|
|
| 62 |
Also reported: **c_sat**, the first intensity at which the smoothed curve reaches
|
| 63 |
probability 0.8, and the raw per-image calls as a downloadable CSV.
|
| 64 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
### Default sequence
|
| 66 |
|
| 67 |
-
The box is pre-filled with the **NUP98 IDP**, wild type. Its
|
| 68 |
-
|
| 69 |
-
`assets/nup98_mutation_seqs.csv`
|
| 70 |
-
is the quickest way to see the model respond
|
|
|
|
| 71 |
|
| 72 |
### Amino acid substitution scan
|
| 73 |
|
|
|
|
| 62 |
Also reported: **c_sat**, the first intensity at which the smoothed curve reaches
|
| 63 |
probability 0.8, and the raw per-image calls as a downloadable CSV.
|
| 64 |
|
| 65 |
+
### Layout
|
| 66 |
+
|
| 67 |
+
The tab is laid out like the Virtual OpenCell one, in the same OpenCell style:
|
| 68 |
+
|
| 69 |
+
- **Navbar** — a picker over the NUP98 panel below. Picking one fills the sequence
|
| 70 |
+
box; typing a panel sequence selects it, and anything else is a custom sequence.
|
| 71 |
+
- **Left** — the sequence's name and composition (aromatic, basic and acidic residue
|
| 72 |
+
counts, net charge), the sequence box and **Run titration**, then the run's AUC, AAC,
|
| 73 |
+
c_sat and condensed-image count, and the titration curve.
|
| 74 |
+
- **Right** — the generated images in OpenCell's colours, nucleus blue and the IDP grey,
|
| 75 |
+
with the same channel switch and per-channel intensity range and gamma. The slider
|
| 76 |
+
steps through all 512 images along the ladder and the strip below holds 64 evenly
|
| 77 |
+
spaced ones; the viewer opens on the image where condensation peaks, captioned with
|
| 78 |
+
its expression level and call. Before a run it shows the reference nucleus alone.
|
| 79 |
+
|
| 80 |
+
Only generation and classification run on the GPU; the curve, viewer and strip are
|
| 81 |
+
drawn afterwards on the CPU, so none of that spends the ZeroGPU budget.
|
| 82 |
+
|
| 83 |
### Default sequence
|
| 84 |
|
| 85 |
+
The box is pre-filled with the **NUP98 IDP**, wild type. Its panel — the wild type and
|
| 86 |
+
91 random F→S mutants, ten draws for each of 1 to 9 substitutions and the one way to
|
| 87 |
+
replace all ten F — ships in `assets/nup98_mutation_seqs.csv` and fills the navbar
|
| 88 |
+
picker; comparing a mutant's AUC against WT is the quickest way to see the model respond
|
| 89 |
+
to sequence grammar, as in the paper's Fig. 5b.
|
| 90 |
|
| 91 |
### Amino acid substitution scan
|
| 92 |
|
app.py
CHANGED
|
@@ -3,7 +3,8 @@
|
|
| 3 |
Enter an IDP sequence; the app generates a concentration ladder of images with
|
| 4 |
CELL-FM, classifies each as condensed or diffuse, smooths the calls into a
|
| 5 |
titration curve, and integrates it into AUC (condensation propensity) and AAC
|
| 6 |
-
(reentrant dissolution).
|
|
|
|
| 7 |
"""
|
| 8 |
|
| 9 |
import os
|
|
@@ -13,11 +14,9 @@ import tempfile
|
|
| 13 |
import gradio as gr
|
| 14 |
import matplotlib
|
| 15 |
matplotlib.use("Agg")
|
| 16 |
-
import matplotlib.pyplot as plt
|
| 17 |
import numpy as np
|
| 18 |
-
import pandas as pd
|
| 19 |
|
| 20 |
-
from cell_fm.apps.condenseq import
|
| 21 |
from cell_fm.apps.virtual_opencell import viewer
|
| 22 |
|
| 23 |
# gradio_client walks component API schemas to build /gradio_api/info. JSON Schema
|
|
@@ -53,121 +52,44 @@ except Exception: # running locally or on a dedicated GPU
|
|
| 53 |
|
| 54 |
HERE = os.path.dirname(os.path.abspath(__file__))
|
| 55 |
|
| 56 |
-
#
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
INK = "#0b0b0b"
|
| 60 |
-
MUTED = "#52514e"
|
| 61 |
-
SURFACE = "#fcfcfb"
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
def load_default_sequence() -> str:
|
| 65 |
-
"""The NUP98 IDP, wild type -- what the box is pre-filled with."""
|
| 66 |
-
df = pd.read_csv(os.path.join(HERE, "assets", "nup98_mutation_seqs.csv"), dtype=str)
|
| 67 |
-
return df.loc[df.mutation == "WT", "sequence"].iloc[0]
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
DEFAULT_SEQUENCE = load_default_sequence()
|
| 71 |
|
| 72 |
# The proteins with virtual staining, and OpenCell's annotations of their real cell lines
|
| 73 |
PROTEINS = viewer.load_catalog(os.path.join(HERE, "assets", "virtual_opencell_proteins.csv"))
|
| 74 |
|
|
|
|
| 75 |
|
| 76 |
-
def plot_curve(result: pipeline.Result):
|
| 77 |
-
"""The titration curve, with the two integrated areas drawn as the areas they are."""
|
| 78 |
-
x, y, curve = result.intensities, result.predictions, result.curve
|
| 79 |
-
flat = metrics.no_reentrant_curve(curve)
|
| 80 |
-
|
| 81 |
-
fig, ax = plt.subplots(figsize=(8.5, 4.6), facecolor=SURFACE)
|
| 82 |
-
ax.set_facecolor(SURFACE)
|
| 83 |
-
|
| 84 |
-
# the raw per-image calls, kept visible but recessive: the curve is a summary of these
|
| 85 |
-
ax.scatter(x, y, s=7, alpha=0.16, color=MUTED, linewidths=0, zorder=1)
|
| 86 |
-
|
| 87 |
-
# AUC: what the sequence actually does
|
| 88 |
-
ax.fill_between(x, 0, curve, color=CURVE, alpha=0.18, linewidth=0, zorder=2,
|
| 89 |
-
label=f"AUC {result.auc:.3f}")
|
| 90 |
-
# AAC: the condensation lost to reentrant dissolution at high concentration
|
| 91 |
-
if result.aac > 1e-9:
|
| 92 |
-
ax.fill_between(x, curve, flat, where=flat > curve, color=GAP, alpha=0.30,
|
| 93 |
-
linewidth=0, zorder=3, label=f"AAC {result.aac:.3f}")
|
| 94 |
-
ax.plot(x, flat, color=GAP, linewidth=1.2, linestyle="--", alpha=0.8, zorder=4)
|
| 95 |
-
|
| 96 |
-
ax.plot(x, curve, color=CURVE, linewidth=2.0, zorder=5)
|
| 97 |
-
|
| 98 |
-
ax.set_xscale("log")
|
| 99 |
-
ax.set_xlim(x.min(), x.max())
|
| 100 |
-
ax.set_ylim(-0.02, 1.02)
|
| 101 |
-
ax.set_xlabel("Protein intensity level (a.u., log scale)", fontsize=11, color=INK)
|
| 102 |
-
ax.set_ylabel("Condensate probability", fontsize=11, color=INK)
|
| 103 |
-
ax.tick_params(labelsize=10, colors=MUTED)
|
| 104 |
-
ax.grid(True, alpha=0.18, linewidth=0.8)
|
| 105 |
-
ax.set_axisbelow(True)
|
| 106 |
-
for side in ("top", "right"):
|
| 107 |
-
ax.spines[side].set_visible(False)
|
| 108 |
-
for side in ("left", "bottom"):
|
| 109 |
-
ax.spines[side].set_color(MUTED)
|
| 110 |
-
ax.spines[side].set_linewidth(0.8)
|
| 111 |
-
|
| 112 |
-
if result.c_sat != float("inf"):
|
| 113 |
-
ax.axvline(result.c_sat, color=MUTED, linewidth=1.0, linestyle=":", zorder=4)
|
| 114 |
-
ax.annotate(
|
| 115 |
-
f"$c_{{sat}}$ {result.c_sat:.0f}",
|
| 116 |
-
xy=(result.c_sat, 1.0), xytext=(6, -13), textcoords="offset points",
|
| 117 |
-
fontsize=9, color=MUTED, ha="left", va="top",
|
| 118 |
-
)
|
| 119 |
-
|
| 120 |
-
ax.legend(loc="upper left", frameon=False, fontsize=10, labelcolor=INK)
|
| 121 |
-
fig.tight_layout()
|
| 122 |
-
return fig
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
def sample_gallery(result: pipeline.Result):
|
| 126 |
-
"""Every generated image along the ladder, captioned with its protein expression level, so the
|
| 127 |
-
curve is checkable by eye."""
|
| 128 |
-
magma = matplotlib.colormaps["magma"]
|
| 129 |
-
return [
|
| 130 |
-
((magma(np.clip(img, 0, 1))[..., :3] * 255).astype(np.uint8), f"{level:.0f}")
|
| 131 |
-
for img, level in zip(result.images, result.image_intensities)
|
| 132 |
-
]
|
| 133 |
|
|
|
|
|
|
|
|
|
|
| 134 |
|
| 135 |
-
def summary_markdown(result: pipeline.Result) -> str:
|
| 136 |
-
return f"""
|
| 137 |
-
| | |
|
| 138 |
-
|---|---|
|
| 139 |
-
| **AUC** — area under the curve, condensation propensity | **{result.auc:.4f}** |
|
| 140 |
-
| **AAC** — area above the curve, reentrant dissolution | **{result.aac:.4f}** |
|
| 141 |
-
| Sequence length | {len(result.sequence)} aa |
|
| 142 |
-
|
| 143 |
-
Both areas are normalised by the width of the log-intensity range, so they lie in
|
| 144 |
-
[0, 1] and are comparable across sequences.
|
| 145 |
-
"""
|
| 146 |
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
"
|
| 153 |
-
|
| 154 |
-
})
|
| 155 |
|
| 156 |
|
| 157 |
@GPU_DECORATOR
|
| 158 |
def analyse(sequence, progress=gr.Progress()):
|
|
|
|
|
|
|
| 159 |
try:
|
| 160 |
sequence = pipeline.clean_sequence(sequence)
|
| 161 |
except ValueError as exc:
|
| 162 |
raise gr.Error(str(exc)) from exc
|
| 163 |
|
| 164 |
result = pipeline.run(sequence, progress=progress)
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
return plot_curve(result), sample_gallery(result), summary_markdown(result), table, csv_path
|
| 171 |
|
| 172 |
|
| 173 |
# ---------------------------------------------------------------------------
|
|
@@ -181,60 +103,140 @@ def analyse(sequence, progress=gr.Progress()):
|
|
| 181 |
|
| 182 |
|
| 183 |
def build_condensate_titration_section():
|
| 184 |
-
"""
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 190 |
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
show_label=False, show_download_button=False, container=False,
|
| 194 |
-
interactive=False,
|
| 195 |
-
)
|
| 196 |
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
)
|
| 207 |
-
run_button = gr.Button("Run analysis", variant="primary")
|
| 208 |
|
| 209 |
-
with gr.
|
| 210 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 217 |
|
| 218 |
-
|
| 219 |
-
table = gr.Dataframe(label="pred_vs_intensity", wrap=True)
|
| 220 |
-
download = gr.File(label="Download CSV")
|
| 221 |
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
|
| 232 |
-
run_button.click(
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
outputs=[curve_plot, sample_plot, summary, table, download],
|
| 236 |
)
|
| 237 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 238 |
|
| 239 |
def build_substitution_scan_section():
|
| 240 |
"""The paper's substitution matrices, mean change in AUC and AAC side by side; a click on
|
|
@@ -245,18 +247,9 @@ def build_substitution_scan_section():
|
|
| 245 |
print(f"[startup] substitution scan unavailable: {exc}")
|
| 246 |
return
|
| 247 |
|
| 248 |
-
def selection_markdown(i, j):
|
| 249 |
-
source, target = scan.amino_acids[i], scan.amino_acids[j]
|
| 250 |
-
n = int(np.isfinite(scan.delta["auc"][i, j]).sum())
|
| 251 |
-
return (
|
| 252 |
-
f"**{source} → {target}** — every {source} in the IDP replaced by {target}. "
|
| 253 |
-
f"Mean over the {n} of {len(scan.idr_index)} IDPs that contain {source}: "
|
| 254 |
-
f"Δ AUC **{scan.mean['auc'][i, j]:+.4f}**, Δ AAC **{scan.mean['aac'][i, j]:+.4f}**."
|
| 255 |
-
)
|
| 256 |
-
|
| 257 |
def view(i, j):
|
| 258 |
maps = [substitution.heatmap(scan, metric, (i, j))[0] for metric in substitution.METRICS]
|
| 259 |
-
return (*maps,
|
| 260 |
substitution.idp_table(scan, i, j))
|
| 261 |
|
| 262 |
# where each matrix sits in its image; the layout does not depend on the selection
|
|
@@ -280,18 +273,7 @@ def build_substitution_scan_section():
|
|
| 280 |
auc0, aac0, selection0, distribution0, table0 = view(
|
| 281 |
*(scan.index(residue) for residue in substitution.DEFAULT_SELECTION))
|
| 282 |
|
| 283 |
-
gr.
|
| 284 |
-
f"""
|
| 285 |
-
---
|
| 286 |
-
### Amino acid substitution scan
|
| 287 |
-
|
| 288 |
-
Every residue type in {len(scan.idr_index)} held-out IDPs replaced by each other type,
|
| 289 |
-
each mutant run through the pipeline above (512 images on the same log-spaced ladder),
|
| 290 |
-
and its AUC and AAC compared with the wild type's. Each cell is the mean change over the
|
| 291 |
-
IDPs that contain the FROM residue: the left matrix is the paper's Fig. 5d, the right its
|
| 292 |
-
Supplementary Fig. 5c. **Click any cell** to see the distribution behind it.
|
| 293 |
-
"""
|
| 294 |
-
)
|
| 295 |
|
| 296 |
# labels hidden: Gradio draws them over the image's top-left corner, where each matrix
|
| 297 |
# carries its own title, as in the paper. They still name the components for the API.
|
|
@@ -305,23 +287,15 @@ def build_substitution_scan_section():
|
|
| 305 |
show_label=False, interactive=False, show_fullscreen_button=False, elem_id="cs-matrix-aac",
|
| 306 |
)
|
| 307 |
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
|
|
|
|
|
|
|
|
|
| 311 |
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
gr.Markdown(
|
| 316 |
-
f"""
|
| 317 |
-
Precomputed, so this part needs no GPU: the per-IDP numbers are
|
| 318 |
-
[`{substitution.SCAN_FILE}`](https://huggingface.co/{substitution.MODEL_REPO}/blob/main/{substitution.SCAN_FILE})
|
| 319 |
-
in the model repo, built by `notebooks/tools/build_substitution_scan_assets.py` from the
|
| 320 |
-
offline scan and checked against the matrices of
|
| 321 |
-
`analysis/ana_all_mutation_log_scale.py`, which drew the paper's figures. AAC there is
|
| 322 |
-
`compute_area_if_no_reentrant_minus_auc`.
|
| 323 |
-
"""
|
| 324 |
-
)
|
| 325 |
|
| 326 |
outputs = [auc_map, aac_map, selection, distribution, idp_table]
|
| 327 |
auc_map.select(select_auc, outputs=outputs, show_progress="hidden")
|
|
@@ -451,11 +425,8 @@ def build_virtual_opencell_section():
|
|
| 451 |
show_progress="hidden", trigger_mode="always_last")
|
| 452 |
|
| 453 |
|
| 454 |
-
#
|
| 455 |
-
CSS = viewer.CSS +
|
| 456 |
-
.cs-gallery .gallery-container { height: 100%; }
|
| 457 |
-
.cs-gallery .grid-wrap { height: 100%; overflow-y: auto; }
|
| 458 |
-
"""
|
| 459 |
|
| 460 |
CITATION = """@article{zheng2026virtual,
|
| 461 |
title = {Virtual experiments bridge sequence and microscopy with generative models},
|
|
@@ -482,7 +453,6 @@ with gr.Blocks(title="CELL-FM", theme=gr.themes.Soft(), css=CSS, head=viewer.HEA
|
|
| 482 |
with gr.Tabs():
|
| 483 |
with gr.Tab("Condensate Titration"):
|
| 484 |
build_condensate_titration_section()
|
| 485 |
-
build_substitution_scan_section()
|
| 486 |
with gr.Tab("Virtual OpenCell"):
|
| 487 |
build_virtual_opencell_section()
|
| 488 |
|
|
|
|
| 3 |
Enter an IDP sequence; the app generates a concentration ladder of images with
|
| 4 |
CELL-FM, classifies each as condensed or diffuse, smooths the calls into a
|
| 5 |
titration curve, and integrates it into AUC (condensation propensity) and AAC
|
| 6 |
+
(reentrant dissolution). Below it, the paper's amino acid substitution scan.
|
| 7 |
+
Both tabs share the Virtual OpenCell look; see cell_fm/apps/condenseq/page.py.
|
| 8 |
"""
|
| 9 |
|
| 10 |
import os
|
|
|
|
| 14 |
import gradio as gr
|
| 15 |
import matplotlib
|
| 16 |
matplotlib.use("Agg")
|
|
|
|
| 17 |
import numpy as np
|
|
|
|
| 18 |
|
| 19 |
+
from cell_fm.apps.condenseq import page, pipeline, substitution
|
| 20 |
from cell_fm.apps.virtual_opencell import viewer
|
| 21 |
|
| 22 |
# gradio_client walks component API schemas to build /gradio_api/info. JSON Schema
|
|
|
|
| 52 |
|
| 53 |
HERE = os.path.dirname(os.path.abspath(__file__))
|
| 54 |
|
| 55 |
+
# The NUP98 panel the navbar picks from, and the name of each sequence in it
|
| 56 |
+
PRESETS = page.load_presets(os.path.join(HERE, "assets", "nup98_mutation_seqs.csv"))
|
| 57 |
+
PRESET_NAMES = {sequence: name for name, sequence in PRESETS.items()}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
# The proteins with virtual staining, and OpenCell's annotations of their real cell lines
|
| 60 |
PROTEINS = viewer.load_catalog(os.path.join(HERE, "assets", "virtual_opencell_proteins.csv"))
|
| 61 |
|
| 62 |
+
LADDER = (pipeline.MIN_INTENSITY, pipeline.MAX_INTENSITY)
|
| 63 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
|
| 65 |
+
def sequence_name(sequence: str) -> str:
|
| 66 |
+
"""A sequence's name in the NUP98 panel, or page.CUSTOM."""
|
| 67 |
+
return PRESET_NAMES.get(page.residues(sequence), page.CUSTOM)
|
| 68 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
|
| 70 |
+
def reference_nucleus():
|
| 71 |
+
"""The nucleus every image is generated in, (H, W) in [0, 1]; None if it cannot be fetched."""
|
| 72 |
+
try:
|
| 73 |
+
return (np.asarray(pipeline.reference_nucleus()[0, 0], dtype=np.float32) + 1) / 2
|
| 74 |
+
except Exception as exc: # the viewer starts empty; a run reports the problem itself
|
| 75 |
+
print(f"[startup] reference nucleus unavailable: {exc}")
|
| 76 |
+
return None
|
|
|
|
| 77 |
|
| 78 |
|
| 79 |
@GPU_DECORATOR
|
| 80 |
def analyse(sequence, progress=gr.Progress()):
|
| 81 |
+
"""Generate and classify, the only work that needs the GPU. Drawing the results happens in a
|
| 82 |
+
follow-up step, so none of it spends the GPU budget."""
|
| 83 |
try:
|
| 84 |
sequence = pipeline.clean_sequence(sequence)
|
| 85 |
except ValueError as exc:
|
| 86 |
raise gr.Error(str(exc)) from exc
|
| 87 |
|
| 88 |
result = pipeline.run(sequence, progress=progress)
|
| 89 |
+
# all the viewer needs, at half the bytes to hand back from the GPU worker
|
| 90 |
+
result.images = result.images.astype(np.float16)
|
| 91 |
+
# the two skipped outputs are there to carry the progress bar while this runs
|
| 92 |
+
return result, gr.skip(), gr.skip()
|
|
|
|
|
|
|
| 93 |
|
| 94 |
|
| 95 |
# ---------------------------------------------------------------------------
|
|
|
|
| 103 |
|
| 104 |
|
| 105 |
def build_condensate_titration_section():
|
| 106 |
+
"""Laid out like the Virtual OpenCell tab: a navbar with a sequence picker; the sequence, its
|
| 107 |
+
numbers and its titration curve on the left; the generated images on the right; then the
|
| 108 |
+
paper's substitution scan, how a titration is computed, and a footer."""
|
| 109 |
+
nucleus = reference_nucleus()
|
| 110 |
+
|
| 111 |
+
def show(result, position, channel, n_min, n_max, n_gamma, t_min, t_max, t_gamma):
|
| 112 |
+
index = int(position) - 1
|
| 113 |
+
return (page.view(result, nucleus, index, channel, (n_min, n_max, n_gamma), (t_min, t_max, t_gamma)),
|
| 114 |
+
page.caption_html(result, index))
|
| 115 |
+
|
| 116 |
+
def show_result(result, *settings):
|
| 117 |
+
"""Everything a finished run fills in, drawn on the CPU."""
|
| 118 |
+
index = int(np.argmax(result.curve)) # open on the image where condensation peaks
|
| 119 |
+
image, caption = show(result, index + 1, *settings)
|
| 120 |
+
# a directory per run, so two visitors' downloads cannot overwrite each other
|
| 121 |
+
path = os.path.join(tempfile.mkdtemp(), "pred_vs_intensity.csv")
|
| 122 |
+
page.results_table(result).to_csv(path, index=False)
|
| 123 |
+
strip = page.thumbnail_indices(len(result.images))
|
| 124 |
+
return (
|
| 125 |
+
page.metrics_html(result, sequence_name(result.sequence)),
|
| 126 |
+
page.curve_figure(result, LADDER),
|
| 127 |
+
image,
|
| 128 |
+
caption,
|
| 129 |
+
gr.Slider(value=index + 1, maximum=len(result.images), interactive=True),
|
| 130 |
+
gr.Column(visible=True),
|
| 131 |
+
gr.Gallery(value=page.thumbnails(result, nucleus), selected_index=int(np.argmin(np.abs(strip - index)))),
|
| 132 |
+
gr.DownloadButton(value=path, interactive=True),
|
| 133 |
+
)
|
| 134 |
|
| 135 |
+
def info(name, sequence):
|
| 136 |
+
return page.info_html(name, sequence, pipeline.NUM_IMAGES, pipeline.DEFAULT_SEED)
|
|
|
|
|
|
|
|
|
|
| 137 |
|
| 138 |
+
def pick_preset(name):
|
| 139 |
+
if name not in PRESETS.index:
|
| 140 |
+
return gr.skip(), gr.skip()
|
| 141 |
+
return PRESETS[name], info(name, PRESETS[name])
|
| 142 |
+
|
| 143 |
+
def edit_sequence(text):
|
| 144 |
+
"""Name what is typed; the picker follows when it matches a panel sequence and clears when not."""
|
| 145 |
+
name = sequence_name(text)
|
| 146 |
+
return gr.Dropdown(value=name if name in PRESETS.index else None), info(name, text)
|
| 147 |
+
|
| 148 |
+
def pick_sample(result, evt: gr.SelectData):
|
| 149 |
+
if result is None:
|
| 150 |
+
return gr.skip()
|
| 151 |
+
return int(page.thumbnail_indices(len(result.images))[evt.index]) + 1
|
| 152 |
+
|
| 153 |
+
defaults = ("Both", 0, 100, 1.0, 0, 100, 1.0)
|
| 154 |
+
image0, caption0 = show(None, 1, *defaults)
|
| 155 |
+
|
| 156 |
+
with gr.Column(elem_id="condensate-titration"):
|
| 157 |
+
with gr.Row(elem_classes="vo-navbar", equal_height=True):
|
| 158 |
+
gr.HTML(page.NAVBAR_HTML)
|
| 159 |
+
preset = gr.Dropdown(
|
| 160 |
+
choices=list(PRESETS.index), value=page.DEFAULT_PRESET, filterable=True,
|
| 161 |
+
show_label=False, container=False, scale=0, min_width=260, elem_classes="vo-search",
|
| 162 |
)
|
|
|
|
| 163 |
|
| 164 |
+
with gr.Row():
|
| 165 |
+
with gr.Column(scale=4):
|
| 166 |
+
info_panel = gr.HTML(info(page.DEFAULT_PRESET, PRESETS[page.DEFAULT_PRESET]))
|
| 167 |
+
sequence = gr.Textbox(
|
| 168 |
+
value=PRESETS[page.DEFAULT_PRESET], lines=3, max_lines=6, label="IDP sequence",
|
| 169 |
+
info=(f"Exactly {pipeline.SEQUENCE_LENGTH} single-letter amino acids, the length of "
|
| 170 |
+
"the whole CondenSeq library; FASTA headers are stripped"),
|
| 171 |
+
elem_classes="cs-sequence",
|
| 172 |
+
)
|
| 173 |
+
run_button = gr.Button("Run titration", elem_classes="cs-run")
|
| 174 |
+
metrics_panel = gr.HTML(page.metrics_html())
|
| 175 |
+
gr.HTML(page.CURVE_HTML, padding=False)
|
| 176 |
+
curve_plot = gr.Plot(value=page.curve_figure(None, LADDER), show_label=False)
|
| 177 |
|
| 178 |
+
with gr.Column(scale=6):
|
| 179 |
+
with gr.Row():
|
| 180 |
+
channel = gr.Radio(page.CHANNELS, value=defaults[0], label="Channel",
|
| 181 |
+
elem_classes="vo-buttons")
|
| 182 |
+
download = gr.DownloadButton("Download CSV", value=None, interactive=False, size="sm",
|
| 183 |
+
variant="secondary", scale=0, min_width=140,
|
| 184 |
+
elem_classes="vo-download")
|
| 185 |
+
image = gr.Image(
|
| 186 |
+
value=image0, show_label=False, interactive=False, height=560,
|
| 187 |
+
show_download_button=True, elem_classes="vo-viewer",
|
| 188 |
+
)
|
| 189 |
+
caption = gr.HTML(caption0, padding=False)
|
| 190 |
+
position = gr.Slider(1, pipeline.NUM_IMAGES, value=1, step=1, interactive=False,
|
| 191 |
+
label="Image along the ladder, lowest expression first")
|
| 192 |
+
# hidden until a run fills it: an empty strip is just a placeholder box
|
| 193 |
+
with gr.Column(visible=False) as samples_box:
|
| 194 |
+
gr.HTML('<div class="vo-settings-label">Samples</div>', padding=False)
|
| 195 |
+
samples = gr.Gallery(
|
| 196 |
+
show_label=False, columns=8, height=230, allow_preview=False, object_fit="contain",
|
| 197 |
+
show_share_button=False, show_download_button=False, elem_classes="vo-thumbnails",
|
| 198 |
+
)
|
| 199 |
+
with gr.Row(elem_classes="vo-settings"):
|
| 200 |
+
with gr.Column():
|
| 201 |
+
gr.HTML('<div class="vo-settings-label">Nucleus image settings</div>', padding=False)
|
| 202 |
+
n_min = gr.Slider(0, 100, value=defaults[1], step=1, label="Intensity min (%)")
|
| 203 |
+
n_max = gr.Slider(1, 150, value=defaults[2], step=1, label="Intensity max (%)")
|
| 204 |
+
n_gamma = gr.Slider(0.5, 1.5, value=defaults[3], step=0.05, label="Gamma")
|
| 205 |
+
with gr.Column():
|
| 206 |
+
gr.HTML('<div class="vo-settings-label">IDP image settings</div>', padding=False)
|
| 207 |
+
t_min = gr.Slider(0, 100, value=defaults[4], step=1, label="Intensity min (%)")
|
| 208 |
+
t_max = gr.Slider(1, 150, value=defaults[5], step=1, label="Intensity max (%)")
|
| 209 |
+
t_gamma = gr.Slider(0.5, 1.5, value=defaults[6], step=0.05, label="Gamma")
|
| 210 |
|
| 211 |
+
build_substitution_scan_section()
|
|
|
|
|
|
|
| 212 |
|
| 213 |
+
gr.HTML(page.HOW_HTML, padding=False)
|
| 214 |
+
gr.Image(
|
| 215 |
+
value=os.path.join(HERE, "images", "cellfm_flow_diagram.png"),
|
| 216 |
+
show_label=False, show_download_button=False, container=False,
|
| 217 |
+
interactive=False, elem_classes="cs-diagram",
|
| 218 |
+
)
|
| 219 |
+
gr.HTML(page.FOOTER_HTML)
|
| 220 |
+
|
| 221 |
+
result_state = gr.State(None)
|
| 222 |
+
settings = [channel, n_min, n_max, n_gamma, t_min, t_max, t_gamma]
|
| 223 |
+
|
| 224 |
+
# .input, not .change: only a visitor's own edits, so neither handler retriggers the other
|
| 225 |
+
preset.input(pick_preset, inputs=preset, outputs=[sequence, info_panel], show_progress="hidden")
|
| 226 |
+
sequence.input(edit_sequence, inputs=sequence, outputs=[preset, info_panel],
|
| 227 |
+
show_progress="hidden", trigger_mode="always_last")
|
| 228 |
|
| 229 |
+
run_button.click(analyse, inputs=sequence, outputs=[result_state, curve_plot, image]).success(
|
| 230 |
+
show_result, inputs=[result_state] + settings,
|
| 231 |
+
outputs=[metrics_panel, curve_plot, image, caption, position, samples_box, samples, download],
|
|
|
|
| 232 |
)
|
| 233 |
|
| 234 |
+
# Redraws take milliseconds: no progress overlay, and a dragged slider only renders its latest value
|
| 235 |
+
samples.select(pick_sample, inputs=result_state, outputs=position, show_progress="hidden")
|
| 236 |
+
for control in [position] + settings:
|
| 237 |
+
control.change(show, inputs=[result_state, position] + settings, outputs=[image, caption],
|
| 238 |
+
show_progress="hidden", trigger_mode="always_last")
|
| 239 |
+
|
| 240 |
|
| 241 |
def build_substitution_scan_section():
|
| 242 |
"""The paper's substitution matrices, mean change in AUC and AAC side by side; a click on
|
|
|
|
| 247 |
print(f"[startup] substitution scan unavailable: {exc}")
|
| 248 |
return
|
| 249 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
def view(i, j):
|
| 251 |
maps = [substitution.heatmap(scan, metric, (i, j))[0] for metric in substitution.METRICS]
|
| 252 |
+
return (*maps, page.substitution_html(scan, i, j), substitution.distribution(scan, i, j),
|
| 253 |
substitution.idp_table(scan, i, j))
|
| 254 |
|
| 255 |
# where each matrix sits in its image; the layout does not depend on the selection
|
|
|
|
| 273 |
auc0, aac0, selection0, distribution0, table0 = view(
|
| 274 |
*(scan.index(residue) for residue in substitution.DEFAULT_SELECTION))
|
| 275 |
|
| 276 |
+
gr.HTML(page.scan_header_html(scan), padding=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 277 |
|
| 278 |
# labels hidden: Gradio draws them over the image's top-left corner, where each matrix
|
| 279 |
# carries its own title, as in the paper. They still name the components for the API.
|
|
|
|
| 287 |
show_label=False, interactive=False, show_fullscreen_button=False, elem_id="cs-matrix-aac",
|
| 288 |
)
|
| 289 |
|
| 290 |
+
with gr.Row():
|
| 291 |
+
with gr.Column(scale=3):
|
| 292 |
+
selection = gr.HTML(selection0, elem_id="cs-selection")
|
| 293 |
+
with gr.Column(scale=7):
|
| 294 |
+
distribution = gr.Plot(value=distribution0, label="Per-IDP changes behind the selected cell",
|
| 295 |
+
show_label=False)
|
| 296 |
|
| 297 |
+
gr.HTML(page.per_idp_html(substitution.MODEL_REPO, substitution.SCAN_FILE), padding=False)
|
| 298 |
+
idp_table = gr.Dataframe(value=table0, wrap=True, max_height=380, show_label=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 299 |
|
| 300 |
outputs = [auc_map, aac_map, selection, distribution, idp_table]
|
| 301 |
auc_map.select(select_auc, outputs=outputs, show_progress="hidden")
|
|
|
|
| 425 |
show_progress="hidden", trigger_mode="always_last")
|
| 426 |
|
| 427 |
|
| 428 |
+
# OpenCell's look for both tabs, each scoped to its own container, plus what only the titration needs
|
| 429 |
+
CSS = viewer.CSS + viewer.scoped_css(page.SCOPE) + page.CSS
|
|
|
|
|
|
|
|
|
|
| 430 |
|
| 431 |
CITATION = """@article{zheng2026virtual,
|
| 432 |
title = {Virtual experiments bridge sequence and microscopy with generative models},
|
|
|
|
| 453 |
with gr.Tabs():
|
| 454 |
with gr.Tab("Condensate Titration"):
|
| 455 |
build_condensate_titration_section()
|
|
|
|
| 456 |
with gr.Tab("Virtual OpenCell"):
|
| 457 |
build_virtual_opencell_section()
|
| 458 |
|
cell_fm/apps/condenseq/page.py
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The Condensate Titration tab's panels, captions and figures, in the Virtual OpenCell tab's look.
|
| 2 |
+
|
| 3 |
+
The two tabs read as one site: the same flat OpenCell styling (viewer.scoped_css), the same layout
|
| 4 |
+
-- a navbar with a picker, an information panel on the left with the plot under it, a black image
|
| 5 |
+
viewer with a sample strip and contrast settings on the right -- and the same channel colours, the
|
| 6 |
+
nucleus blue and the protein grey. Generated images go through viewer.render, so both viewers draw
|
| 7 |
+
alike.
|
| 8 |
+
|
| 9 |
+
Nothing here touches the models: the app hands in a pipeline.Result and the reference nucleus.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import html
|
| 13 |
+
import re
|
| 14 |
+
|
| 15 |
+
import numpy as np
|
| 16 |
+
import pandas as pd
|
| 17 |
+
from matplotlib.figure import Figure
|
| 18 |
+
|
| 19 |
+
from ..virtual_opencell import viewer
|
| 20 |
+
from . import metrics
|
| 21 |
+
|
| 22 |
+
SCOPE = "#condensate-titration"
|
| 23 |
+
|
| 24 |
+
# the plot palette: categorical slots 1 and 2 (validated, CVD dE 24.7 / normal dE 33.6)
|
| 25 |
+
CURVE = "#2a78d6" # the titration curve and the area under it
|
| 26 |
+
GAP = "#eb6834" # the area above the curve
|
| 27 |
+
INK = "#0b0b0b"
|
| 28 |
+
MUTED = "#52514e"
|
| 29 |
+
BACKGROUND = "#fff" # plots sit on white, like the Virtual OpenCell map
|
| 30 |
+
|
| 31 |
+
NUM_THUMBNAILS = 64 # the sample strip: evenly spaced along the ladder; the slider reaches the rest
|
| 32 |
+
|
| 33 |
+
# OpenCell's channel switch. viewer.render calls the protein channel the "target"; here it is the IDP
|
| 34 |
+
CHANNELS = [("Nucleus", "Nucleus"), ("IDP", "Target"), ("Both", "Both")]
|
| 35 |
+
|
| 36 |
+
DEFAULT_PRESET = "NUP98 WT"
|
| 37 |
+
CUSTOM = "Custom sequence"
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _item(label: str, value: str) -> str:
|
| 41 |
+
return (f'<div class="vo-metadata-item"><div class="vo-metadata-label">{label}</div>'
|
| 42 |
+
f'<div class="vo-metadata-value">{value}</div></div>')
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
# ---------------------------------------------------------------------------
|
| 46 |
+
# The sequence picker
|
| 47 |
+
# ---------------------------------------------------------------------------
|
| 48 |
+
|
| 49 |
+
def load_presets(path: str) -> pd.Series:
|
| 50 |
+
"""The NUP98 IDP panel shipped with the app, display name -> sequence, wild type first.
|
| 51 |
+
|
| 52 |
+
The wild type and random F -> S mutants of it: ten draws for each of 1 to 9 substitutions and
|
| 53 |
+
the one way to make all ten, the virtual mutagenesis of the paper's Fig. 5b.
|
| 54 |
+
"""
|
| 55 |
+
table = pd.read_csv(path, dtype=str)
|
| 56 |
+
return pd.Series(table["sequence"].to_numpy(), index=table["mutation"].map(_preset_name).to_numpy())
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def _preset_name(mutation: str) -> str:
|
| 60 |
+
if mutation == "WT":
|
| 61 |
+
return DEFAULT_PRESET
|
| 62 |
+
count, draw = re.fullmatch(r"(\d+)FtoS_(\d+)", mutation).groups()
|
| 63 |
+
return f"NUP98 {count}F→S #{draw}"
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def residues(text: str) -> str:
|
| 67 |
+
"""The sequence a box holds, the way pipeline.clean_sequence reads it, without its checks."""
|
| 68 |
+
lines = [ln for ln in str(text).splitlines() if not ln.startswith(">")]
|
| 69 |
+
return "".join("".join(lines).split()).upper()
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def describe(name: str) -> str:
|
| 73 |
+
if name == DEFAULT_PRESET:
|
| 74 |
+
return "The NUP98 FG-repeat IDP, wild type"
|
| 75 |
+
match = re.fullmatch(r"NUP98 (\d+)F→S #(\d+)", name)
|
| 76 |
+
if match:
|
| 77 |
+
count, draw = match.groups()
|
| 78 |
+
return f"NUP98 IDP with {count} of its 10 phenylalanines mutated to serine, draw {draw}"
|
| 79 |
+
return "A sequence entered by hand"
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def info_html(name: str, sequence: str, num_images: int, seed: int) -> str:
|
| 83 |
+
"""The left-hand panel, OpenCell's target header: which sequence this is and what it is made of.
|
| 84 |
+
|
| 85 |
+
The composition counts are the residues the substitution scan below finds matter most:
|
| 86 |
+
aromatics and arginine drive condensation, acidic residues oppose it.
|
| 87 |
+
"""
|
| 88 |
+
seq = residues(sequence)
|
| 89 |
+
aromatic, basic, acidic = (sum(seq.count(a) for a in letters) for letters in ("FYW", "RK", "DE"))
|
| 90 |
+
items = [
|
| 91 |
+
_item("Length", f"{len(seq)} aa"),
|
| 92 |
+
_item("Aromatic (F Y W)", str(aromatic)),
|
| 93 |
+
_item("Basic (R K)", str(basic)),
|
| 94 |
+
_item("Acidic (D E)", str(acidic)),
|
| 95 |
+
_item("Net charge", f"{basic - acidic:+d}"),
|
| 96 |
+
]
|
| 97 |
+
return f"""
|
| 98 |
+
<div class="vo-target">
|
| 99 |
+
<div class="vo-target-name">{html.escape(name)}</div>
|
| 100 |
+
<div class="vo-protein-description">{html.escape(describe(name))}</div>
|
| 101 |
+
<div class="vo-status">CELL-FM condensate titration · {num_images} images · seed {seed}</div>
|
| 102 |
+
<div class="vo-metadata">{''.join(items)}</div>
|
| 103 |
+
</div>
|
| 104 |
+
"""
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
# ---------------------------------------------------------------------------
|
| 108 |
+
# A run's numbers and curve
|
| 109 |
+
# ---------------------------------------------------------------------------
|
| 110 |
+
|
| 111 |
+
def metrics_html(result=None, name: str = "") -> str:
|
| 112 |
+
"""AUC, AAC, c_sat and the condensed count, as OpenCell metadata; dashes before any run."""
|
| 113 |
+
if result is None:
|
| 114 |
+
values = ["—"] * 4
|
| 115 |
+
measured = "Run the titration to measure them."
|
| 116 |
+
else:
|
| 117 |
+
c_sat = "not reached" if result.c_sat == float("inf") else f"{result.c_sat:.0f} a.u."
|
| 118 |
+
values = [f"{result.auc:.4f}", f"{result.aac:.4f}", c_sat,
|
| 119 |
+
f"{int(result.predictions.sum())} / {len(result.predictions)}"]
|
| 120 |
+
measured = f"Measured for {html.escape(name)}."
|
| 121 |
+
labels = ["AUC", "AAC", "c<sub>sat</sub>", "Condensed images"]
|
| 122 |
+
return f"""
|
| 123 |
+
<div class="vo-target cs-metrics">
|
| 124 |
+
<div class="vo-section-header">Titration</div>
|
| 125 |
+
<div class="vo-section-caption">{measured} AUC is how much of the scanned range the IDP spends
|
| 126 |
+
condensed; AAC how much of that it loses to re-dissolving at high expression. Both are normalised
|
| 127 |
+
to [0, 1]. c<sub>sat</sub> is the first level at which the curve reaches 0.8.</div>
|
| 128 |
+
<div class="vo-metadata">{''.join(_item(l, v) for l, v in zip(labels, values))}</div>
|
| 129 |
+
</div>
|
| 130 |
+
"""
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
CURVE_HTML = """
|
| 134 |
+
<div class="vo-target">
|
| 135 |
+
<div class="vo-section-header">Titration curve</div>
|
| 136 |
+
<div class="vo-section-caption">One grey dot per generated image, called condensed (1) or diffuse (0)
|
| 137 |
+
by the ViT; the line is their moving average, the condensate probability. Blue is the area under
|
| 138 |
+
it (AUC), orange the condensation lost to re-dissolving past the peak (AAC).</div>
|
| 139 |
+
</div>
|
| 140 |
+
"""
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def _style(ax):
|
| 144 |
+
ax.set_facecolor(BACKGROUND)
|
| 145 |
+
ax.tick_params(labelsize=10, colors=MUTED)
|
| 146 |
+
ax.grid(True, alpha=0.18, linewidth=0.8)
|
| 147 |
+
ax.set_axisbelow(True)
|
| 148 |
+
for side in ("top", "right"):
|
| 149 |
+
ax.spines[side].set_visible(False)
|
| 150 |
+
for side in ("left", "bottom"):
|
| 151 |
+
ax.spines[side].set_color(MUTED)
|
| 152 |
+
ax.spines[side].set_linewidth(0.8)
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def curve_figure(result, ladder) -> Figure:
|
| 156 |
+
"""The titration curve, with the two integrated areas drawn as the areas they are.
|
| 157 |
+
|
| 158 |
+
ladder is the (lowest, highest) expression level; before any run the axes are drawn empty
|
| 159 |
+
over it, so the panel has its final shape from the start.
|
| 160 |
+
"""
|
| 161 |
+
fig = Figure(figsize=(7.4, 4.4), dpi=100, facecolor=BACKGROUND)
|
| 162 |
+
ax = fig.add_subplot()
|
| 163 |
+
_style(ax)
|
| 164 |
+
|
| 165 |
+
if result is None:
|
| 166 |
+
ax.text(0.5, 0.5, "Run the titration to draw the curve", transform=ax.transAxes,
|
| 167 |
+
ha="center", va="center", fontsize=11, color=MUTED)
|
| 168 |
+
else:
|
| 169 |
+
x, y, curve = result.intensities, result.predictions, result.curve
|
| 170 |
+
flat = metrics.no_reentrant_curve(curve)
|
| 171 |
+
|
| 172 |
+
# the raw per-image calls, kept visible but recessive: the curve is a summary of these
|
| 173 |
+
ax.scatter(x, y, s=7, alpha=0.16, color=MUTED, linewidths=0, zorder=1)
|
| 174 |
+
# AUC: what the sequence actually does
|
| 175 |
+
ax.fill_between(x, 0, curve, color=CURVE, alpha=0.18, linewidth=0, zorder=2,
|
| 176 |
+
label=f"AUC {result.auc:.3f}")
|
| 177 |
+
# AAC: the condensation lost to reentrant dissolution at high concentration
|
| 178 |
+
if result.aac > 1e-9:
|
| 179 |
+
ax.fill_between(x, curve, flat, where=flat > curve, color=GAP, alpha=0.30,
|
| 180 |
+
linewidth=0, zorder=3, label=f"AAC {result.aac:.3f}")
|
| 181 |
+
ax.plot(x, flat, color=GAP, linewidth=1.2, linestyle="--", alpha=0.8, zorder=4)
|
| 182 |
+
ax.plot(x, curve, color=CURVE, linewidth=2.0, zorder=5)
|
| 183 |
+
|
| 184 |
+
if result.c_sat != float("inf"):
|
| 185 |
+
ax.axvline(result.c_sat, color=MUTED, linewidth=1.0, linestyle=":", zorder=4)
|
| 186 |
+
ax.annotate(f"$c_{{sat}}$ {result.c_sat:.0f}", xy=(result.c_sat, 1.0), xytext=(6, -13),
|
| 187 |
+
textcoords="offset points", fontsize=9, color=MUTED, ha="left", va="top")
|
| 188 |
+
ax.legend(loc="upper left", frameon=False, fontsize=10, labelcolor=INK)
|
| 189 |
+
|
| 190 |
+
ax.set_xscale("log")
|
| 191 |
+
ax.set_xlim(*ladder)
|
| 192 |
+
ax.set_ylim(-0.02, 1.02)
|
| 193 |
+
ax.set_xlabel("Protein expression level (a.u., log scale)", fontsize=11, color=INK)
|
| 194 |
+
ax.set_ylabel("Condensate probability", fontsize=11, color=INK)
|
| 195 |
+
fig.tight_layout()
|
| 196 |
+
return fig
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def results_table(result) -> pd.DataFrame:
|
| 200 |
+
"""The numbers behind the plot, so the chart is never the only way to read them."""
|
| 201 |
+
return pd.DataFrame({
|
| 202 |
+
"protein_intensity_level": np.round(result.intensities, 4),
|
| 203 |
+
"predicted_class": result.predictions,
|
| 204 |
+
"condensate_probability_ma": np.round(result.curve, 6),
|
| 205 |
+
})
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
# ---------------------------------------------------------------------------
|
| 209 |
+
# The image viewer and sample strip
|
| 210 |
+
# ---------------------------------------------------------------------------
|
| 211 |
+
|
| 212 |
+
def _sample(result, nucleus: np.ndarray, index: int) -> np.ndarray:
|
| 213 |
+
"""One image as viewer.render takes it: (2, H, W), nucleus first, on the uint16 scale its
|
| 214 |
+
auto-contrast expects. Before any run the IDP channel is empty."""
|
| 215 |
+
protein = np.zeros_like(nucleus) if result is None else result.images[index].astype(np.float32)
|
| 216 |
+
return np.stack([nucleus, protein]) * 65535.0
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def view(result, nucleus, index: int, channel: str = "Both",
|
| 220 |
+
nucleus_settings=(0, 100, 1.0), target_settings=(0, 100, 1.0)):
|
| 221 |
+
"""Image `index` of the ladder as an RGB array; the reference nucleus alone before a run."""
|
| 222 |
+
if nucleus is None:
|
| 223 |
+
return None
|
| 224 |
+
if result is not None:
|
| 225 |
+
index = int(np.clip(index, 0, len(result.images) - 1))
|
| 226 |
+
return viewer.render(_sample(result, nucleus, index), channel, nucleus_settings, target_settings)
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
def caption_html(result=None, index: int = 0) -> str:
|
| 230 |
+
if result is None:
|
| 231 |
+
return ('<div class="cs-viewer-caption">The reference nucleus every image is generated in. '
|
| 232 |
+
'Run the titration to add the IDP.</div>')
|
| 233 |
+
index = int(np.clip(index, 0, len(result.images) - 1))
|
| 234 |
+
call = "condensed" if result.predictions[index] else "diffuse"
|
| 235 |
+
return (f'<div class="cs-viewer-caption">Image {index + 1} of {len(result.images)} · '
|
| 236 |
+
f'expression level <b>{result.image_intensities[index]:.0f}</b> a.u. · '
|
| 237 |
+
f'called <b>{call}</b> · condensate probability {result.curve[index]:.2f}</div>')
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def thumbnail_indices(n: int) -> np.ndarray:
|
| 241 |
+
"""Which images of an n-image ladder the strip shows."""
|
| 242 |
+
return np.unique(np.linspace(0, n - 1, min(n, NUM_THUMBNAILS)).round().astype(int))
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
def thumbnails(result, nucleus) -> list:
|
| 246 |
+
"""(image, caption) per strip entry, both channels at default settings, captioned with the level."""
|
| 247 |
+
return [(view(result, nucleus, i), f"{result.image_intensities[i]:.0f}")
|
| 248 |
+
for i in thumbnail_indices(len(result.images))]
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
# ---------------------------------------------------------------------------
|
| 252 |
+
# The substitution scan
|
| 253 |
+
# ---------------------------------------------------------------------------
|
| 254 |
+
|
| 255 |
+
def scan_header_html(scan) -> str:
|
| 256 |
+
return f"""
|
| 257 |
+
<div class="vo-target">
|
| 258 |
+
<div class="vo-section-header">Amino acid substitution scan</div>
|
| 259 |
+
<div class="vo-section-caption">Every residue type in {len(scan.idr_index)} held-out IDPs replaced by
|
| 260 |
+
each other type, each mutant titrated as above, and its AUC and AAC compared with the wild type's.
|
| 261 |
+
A cell is the mean change over the IDPs that contain the FROM residue: left the paper's Fig. 5d
|
| 262 |
+
(AUC), right its Supplementary Fig. 5c (AAC). Click any cell for the distribution behind it.</div>
|
| 263 |
+
</div>
|
| 264 |
+
"""
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
def substitution_html(scan, i: int, j: int) -> str:
|
| 268 |
+
"""The selected substitution, named large like a target, with its summary statistics."""
|
| 269 |
+
source, target = scan.amino_acids[i], scan.amino_acids[j]
|
| 270 |
+
items, n = [], 0
|
| 271 |
+
for metric in ("auc", "aac"):
|
| 272 |
+
values = scan.delta[metric][i, j]
|
| 273 |
+
values = values[np.isfinite(values)]
|
| 274 |
+
n = len(values)
|
| 275 |
+
label = metric.upper()
|
| 276 |
+
items += [_item(f"Mean Δ {label}", f"{values.mean():+.4f}"),
|
| 277 |
+
_item(f"Median Δ {label}", f"{np.median(values):+.4f}")]
|
| 278 |
+
return f"""
|
| 279 |
+
<div class="vo-target">
|
| 280 |
+
<div class="cs-substitution">{source} → {target}</div>
|
| 281 |
+
<div class="vo-status">Every {source} in the IDP replaced by {target} · {n} of
|
| 282 |
+
{len(scan.idr_index)} IDPs contain {source}</div>
|
| 283 |
+
<div class="vo-metadata cs-metrics">{''.join(items)}</div>
|
| 284 |
+
</div>
|
| 285 |
+
"""
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
def per_idp_html(repo: str, filename: str) -> str:
|
| 289 |
+
return f"""
|
| 290 |
+
<div class="vo-target">
|
| 291 |
+
<div class="vo-section-header">Per-IDP values</div>
|
| 292 |
+
<div class="vo-section-caption">One row per IDP behind the selected cell; click a header to sort.
|
| 293 |
+
Precomputed: the whole scan is <a href="https://huggingface.co/{repo}/blob/main/{filename}"
|
| 294 |
+
target="_blank">{filename}</a> in the model repo, checked against the matrices the paper's figures
|
| 295 |
+
were drawn from (<code>ana_all_mutation_log_scale.py</code>, AAC being
|
| 296 |
+
<code>compute_area_if_no_reentrant_minus_auc</code>).</div>
|
| 297 |
+
</div>
|
| 298 |
+
"""
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
# ---------------------------------------------------------------------------
|
| 302 |
+
# Static text
|
| 303 |
+
# ---------------------------------------------------------------------------
|
| 304 |
+
|
| 305 |
+
NAVBAR_HTML = """
|
| 306 |
+
<div class="vo-navbar-brand">
|
| 307 |
+
<span class="vo-navbar-title">Condensate Titration</span>
|
| 308 |
+
<span class="vo-navbar-menu">CELL-FM virtual CondenSeq experiments on 66-aa IDPs</span>
|
| 309 |
+
</div>
|
| 310 |
+
"""
|
| 311 |
+
|
| 312 |
+
HOW_HTML = """
|
| 313 |
+
<div class="vo-target">
|
| 314 |
+
<div class="vo-section-header">How a titration is computed</div>
|
| 315 |
+
<div class="vo-section-caption">CELL-FM generates one image per expression level along a log-spaced
|
| 316 |
+
ladder, a ViT classifier calls each image condensed or diffuse, a moving average over the calls is
|
| 317 |
+
the titration curve, and the curve is integrated against log expression into AUC and AAC.</div>
|
| 318 |
+
</div>
|
| 319 |
+
"""
|
| 320 |
+
|
| 321 |
+
FOOTER_HTML = """
|
| 322 |
+
<div class="vo-footer">
|
| 323 |
+
Each image is CELL-FM's prediction from the IDP sequence and a protein expression level alone,
|
| 324 |
+
drawn in one shared cell: the nucleus channel is the same real CondenSeq nucleus for every image,
|
| 325 |
+
so differences come from the sequence and the level. Sampling is fixed at 512 levels from 20 to
|
| 326 |
+
2048 a.u., 100 ODE steps and seed 6, as in the offline runs. The IDP library and the condensate
|
| 327 |
+
phenotypes the models learned from are CondenSeq's
|
| 328 |
+
(Kappel et al., <i>Nat. Methods</i> 2025).
|
| 329 |
+
</div>
|
| 330 |
+
"""
|
| 331 |
+
|
| 332 |
+
# Additions to the OpenCell look for what the Virtual OpenCell tab does not have
|
| 333 |
+
CSS = """
|
| 334 |
+
#condensate-titration .cs-sequence textarea {
|
| 335 |
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace !important;
|
| 336 |
+
font-size: 15px !important; line-height: 1.55; background: #fff;
|
| 337 |
+
}
|
| 338 |
+
#condensate-titration .cs-run {
|
| 339 |
+
background: var(--vo-blue) !important; color: #fff !important; border: none !important;
|
| 340 |
+
border-radius: 5px !important; font-family: "Lato", sans-serif; font-size: 17px; letter-spacing: 0.5px;
|
| 341 |
+
}
|
| 342 |
+
#condensate-titration .cs-run:hover { background: #0189bd !important; }
|
| 343 |
+
#condensate-titration .cs-metrics .vo-metadata-value { font-family: "Lato", sans-serif; font-size: 26px; font-weight: 300; }
|
| 344 |
+
#condensate-titration .cs-viewer-caption { font-family: "Lato", sans-serif; font-size: 14px; color: #555; margin: 2px 0; }
|
| 345 |
+
#condensate-titration .cs-viewer-caption b { color: #182026; font-weight: 600; }
|
| 346 |
+
#condensate-titration .cs-substitution { font-family: "Lato", sans-serif; font-size: 44px; font-weight: 400; line-height: 1.1; }
|
| 347 |
+
#condensate-titration .cs-diagram img { max-width: 1100px; margin: 0 auto; }
|
| 348 |
+
"""
|
cell_fm/apps/condenseq/substitution.py
CHANGED
|
@@ -38,7 +38,7 @@ PAPER_FIGURES = {"auc": "Fig. 5d", "aac": "Supplementary Fig. 5c"}
|
|
| 38 |
COLORS = {"auc": "#2a78d6", "aac": "#eb6834"}
|
| 39 |
INK = "#0b0b0b"
|
| 40 |
MUTED = "#52514e"
|
| 41 |
-
SURFACE = "#
|
| 42 |
|
| 43 |
# Shown before any click: F -> S, the substitution the paper's NUP98 validation makes one F at a time
|
| 44 |
DEFAULT_SELECTION = ("F", "S")
|
|
|
|
| 38 |
COLORS = {"auc": "#2a78d6", "aac": "#eb6834"}
|
| 39 |
INK = "#0b0b0b"
|
| 40 |
MUTED = "#52514e"
|
| 41 |
+
SURFACE = "#fff" # white, like the tab's other plots and the Virtual OpenCell map
|
| 42 |
|
| 43 |
# Shown before any click: F -> S, the substitution the paper's NUP98 validation makes one F at a time
|
| 44 |
DEFAULT_SELECTION = ("F", "S")
|
cell_fm/apps/virtual_opencell/viewer.py
CHANGED
|
@@ -413,3 +413,9 @@ CSS = """
|
|
| 413 |
background: #eee; border-radius: 6px; padding: 10px 16px; margin-top: 10px; font-size: 13px; color: #333;
|
| 414 |
}
|
| 415 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 413 |
background: #eee; border-radius: 6px; padding: 10px 16px; margin-top: 10px; font-size: 13px; color: #333;
|
| 414 |
}
|
| 415 |
"""
|
| 416 |
+
|
| 417 |
+
|
| 418 |
+
def scoped_css(scope: str) -> str:
|
| 419 |
+
"""The same look for another container: every rule above is written against #virtual-opencell,
|
| 420 |
+
so re-pointing them gives a second tab OpenCell's style without touching this one's."""
|
| 421 |
+
return CSS.replace("#virtual-opencell", scope)
|