BoHuangLab commited on
Commit
e8ad02e
·
verified ·
1 Parent(s): 3c4fb84

Condensate Titration: Virtual OpenCell layout and style; GPU only generates and classifies

Browse files
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 mutation panel — 92
68
- sequences of 66 aa covering F→S, F→Y, charge and spacer variants — ships in
69
- `assets/nup98_mutation_seqs.csv`; paste one in to compare its AUC against WT, which
70
- is the quickest way to see the model respond to sequence grammar.
 
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 metrics, pipeline, substitution
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
- # palette: categorical slots 1 and 2 (validated, CVD dE 24.7 / normal dE 33.6)
57
- CURVE = "#2a78d6" # the titration curve and the area under it
58
- GAP = "#eb6834" # the area above the curve
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
- def results_table(result: pipeline.Result) -> pd.DataFrame:
149
- """The numbers behind the plot, so the chart is never the only way to read them."""
150
- return pd.DataFrame({
151
- "protein_intensity_level": np.round(result.intensities, 4),
152
- "predicted_class": result.predictions,
153
- "condensate_probability_ma": np.round(result.curve, 6),
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
- table = results_table(result)
167
- csv_path = os.path.join("/tmp", "pred_vs_intensity.csv")
168
- table.to_csv(csv_path, index=False)
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
- """Sequence in, condensate titration curve out, summarised as AUC and AAC."""
185
- gr.Markdown(
186
- """
187
- Predict how an Intrinsically Disordered Peptide (IDP) behaves as its concentration rises.
188
- """
189
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
190
 
191
- gr.Image(
192
- value=os.path.join(HERE, "images", "cellfm_flow_diagram.png"),
193
- show_label=False, show_download_button=False, container=False,
194
- interactive=False,
195
- )
196
 
197
- with gr.Row():
198
- with gr.Column(scale=2):
199
- sequence = gr.Textbox(
200
- value=DEFAULT_SEQUENCE, lines=4,
201
- label="IDP sequence",
202
- info=(
203
- f"Exactly {pipeline.SEQUENCE_LENGTH} single-letter amino acids — the whole "
204
- "CondenSeq library is 66-mers. FASTA headers are stripped."
205
- ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
206
  )
207
- run_button = gr.Button("Run analysis", variant="primary")
208
 
209
- with gr.Column(scale=3):
210
- summary = gr.Markdown()
 
 
 
 
 
 
 
 
 
 
 
211
 
212
- curve_plot = gr.Plot(label="Condensate titration curve")
213
- sample_plot = gr.Gallery(
214
- label="Generated images (protein channel), captioned with the protein expression level (a.u.)",
215
- columns=8, height=520, allow_preview=True, object_fit="contain", elem_classes="cs-gallery",
216
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
217
 
218
- with gr.Accordion("Per-image results", open=False):
219
- table = gr.Dataframe(label="pred_vs_intensity", wrap=True)
220
- download = gr.File(label="Download CSV")
221
 
222
- gr.Markdown(
223
- """
224
- ---
225
- Generation follows `evaluate_seq2img_dict.py`, classification `evaluate_single_img.py`,
226
- smoothing `ma_plot.py`, and the areas `analysis/ana_all_mutation_log_scale.py` from the
227
- CELL-FM repository. Every image is conditioned on one fixed reference nucleus, so curves
228
- are comparable across sequences.
229
- """
230
- )
 
 
 
 
 
 
231
 
232
- run_button.click(
233
- analyse,
234
- inputs=sequence,
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, selection_markdown(i, j), substitution.distribution(scan, i, j),
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.Markdown(
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
- selection = gr.Markdown(selection0, elem_id="cs-selection")
309
- distribution = gr.Plot(value=distribution0, label="Per-IDP changes behind the selected cell",
310
- show_label=False)
 
 
 
311
 
312
- with gr.Accordion("Per-IDP values", open=False):
313
- idp_table = gr.Dataframe(value=table0, wrap=True)
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
- # Gradio's gallery block clips at its height; make the grid scroll inside it instead
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 &middot; {num_images} images &middot; 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)} &middot; '
236
+ f'expression level <b>{result.image_intensities[index]:.0f}</b> a.u. &middot; '
237
+ f'called <b>{call}</b> &middot; 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} &middot; {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 = "#fcfcfb"
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)