BoHuangLab commited on
Commit
2d03a2d
·
verified ·
1 Parent(s): e57ebbb

Virtual OpenCell: switch between 2D and 3D virtual staining; 3D shown one confocal slice at a time

Browse files
Files changed (2) hide show
  1. app.py +58 -31
  2. cell_fm/apps/virtual_opencell/viewer.py +62 -11
app.py CHANGED
@@ -342,29 +342,38 @@ def build_substitution_scan_section():
342
 
343
  def build_virtual_opencell_section():
344
  """Pick an OpenCell protein and browse CELL-FM's virtual staining of it, laid out like an
345
- OpenCell target page: the real cell line's annotations on the left, the images on the right."""
 
346
 
347
  def load(gene):
348
  row = PROTEINS.loc[gene]
349
  stack, paths = viewer.load_samples(gene, int(row["n_samples"]))
350
  return row, stack, paths
351
 
352
- def show(gene, index, channel, n_min, n_max, n_gamma, t_min, t_max, t_gamma):
 
 
 
 
353
  _, stack, _ = load(gene)
354
- return viewer.render(stack[index], channel, (n_min, n_max, n_gamma), (t_min, t_max, t_gamma))
355
 
356
- def sample_file(gene, index):
357
  """The raw sample under a readable name, copied where Gradio is allowed to serve it."""
358
- _, _, paths = load(gene)
359
- path = os.path.join(tempfile.gettempdir(), f"virtual_opencell_{gene}_{index + 1:04d}.tif")
360
- shutil.copyfile(paths[index], path)
 
 
 
361
  return path
362
 
363
- def protein_view(gene, *settings):
364
  """Panel, thumbnails, first sample and its file for one protein."""
365
- row, stack, _ = load(gene)
366
- image = show(gene, 0, *settings)
367
- return viewer.target_html(gene, row), viewer.thumbnails(stack), image, sample_file(gene, 0)
 
368
 
369
  def umap_plot(gene):
370
  try:
@@ -373,21 +382,32 @@ def build_virtual_opencell_section():
373
  print(f"[virtual opencell] map unavailable: {exc}")
374
  return None
375
 
376
- def select_protein(gene, channel, n_min, n_max, n_gamma, t_min, t_max, t_gamma):
377
  try:
378
- info, thumbs, image, path = protein_view(gene, channel, n_min, n_max, n_gamma, t_min, t_max, t_gamma)
379
  except Exception as exc:
380
- raise gr.Error(f"Could not load the images for {gene}: {viewer.load_error(exc)}") from exc
381
- return info, gr.Gallery(value=thumbs, selected_index=0), image, 0, path, umap_plot(gene)
382
-
383
- def select_sample(gene, channel, n_min, n_max, n_gamma, t_min, t_max, t_gamma, evt: gr.SelectData):
384
- image = show(gene, evt.index, channel, n_min, n_max, n_gamma, t_min, t_max, t_gamma)
385
- return image, evt.index, sample_file(gene, evt.index)
386
-
387
- # Shown on first load: the default protein, unless its images cannot be fetched
 
 
 
 
 
 
 
 
 
 
388
  defaults = ("Both", 0, 100, 1.0, 0, 100, 1.0)
 
389
  try:
390
- info0, thumbs0, image0, file0 = protein_view(viewer.DEFAULT_GENE, *defaults)
391
  except Exception as exc: # e.g. no access to the dataset; the error is shown in the panel
392
  print(f"[startup] virtual OpenCell preload failed: {exc}")
393
  info0, thumbs0, image0, file0 = viewer.error_html(viewer.DEFAULT_GENE, exc), None, None, None
@@ -409,11 +429,13 @@ def build_virtual_opencell_section():
409
  with gr.Row():
410
  with gr.Column(scale=4):
411
  info = gr.HTML(info0)
412
- gr.HTML(viewer.UMAP_HTML, padding=False)
413
  umap = gr.Plot(value=umap0, show_label=False, elem_classes="vo-umap")
414
 
415
  with gr.Column(scale=6):
416
  with gr.Row():
 
 
417
  channel = gr.Radio(viewer.CHANNELS, value=defaults[0], label="Channel",
418
  elem_classes="vo-buttons")
419
  download = gr.DownloadButton("Download TIFF", value=file0, size="sm", variant="secondary",
@@ -422,6 +444,9 @@ def build_virtual_opencell_section():
422
  value=image0, show_label=False, interactive=False, height=600,
423
  show_download_button=True, elem_classes="vo-viewer",
424
  )
 
 
 
425
  gr.HTML('<div class="vo-settings-label">Samples</div>', padding=False)
426
  samples = gr.Gallery(
427
  value=thumbs0, selected_index=0 if thumbs0 else None, show_label=False,
@@ -442,24 +467,26 @@ def build_virtual_opencell_section():
442
 
443
  # the full map: every image and every protein named. One fixed figure (~2 MB) sent with the
444
  # page; the panel map above follows the selection, so this one never has to be re-sent
445
- gr.HTML('<div class="vo-target" style="text-align: center; padding-right: 0"><div class="vo-section-header">Full virtual staining map</div>'
446
- '<div class="vo-section-caption">All 93,510 generated images, each protein named at the '
447
- 'centre of its images; zoom in to separate the names</div></div>', padding=False)
448
- gr.Plot(value=full_map0, show_label=False, elem_classes="vo-umap-full")
449
 
450
  gr.HTML(viewer.FOOTER_HTML)
451
 
452
  index = gr.State(0)
453
  settings = [channel, n_min, n_max, n_gamma, t_min, t_max, t_gamma]
454
 
455
- protein.change(select_protein, inputs=[protein] + settings,
456
  outputs=[info, samples, image, index, download, umap])
 
 
457
 
458
  # Redraws take milliseconds: no progress overlay, and a dragged slider only renders its latest value
459
- samples.select(select_sample, inputs=[protein] + settings, outputs=[image, index, download],
460
  show_progress="hidden")
461
- for control in settings:
462
- control.change(show, inputs=[protein, index] + settings, outputs=image,
463
  show_progress="hidden", trigger_mode="always_last")
464
 
465
 
 
342
 
343
  def build_virtual_opencell_section():
344
  """Pick an OpenCell protein and browse CELL-FM's virtual staining of it, laid out like an
345
+ OpenCell target page: the real cell line's annotations on the left, the images on the right.
346
+ "2D" is the 2D projection set, with its map; "3D" the CELL-FM 3D stacks, one z slice at a time."""
347
 
348
  def load(gene):
349
  row = PROTEINS.loc[gene]
350
  stack, paths = viewer.load_samples(gene, int(row["n_samples"]))
351
  return row, stack, paths
352
 
353
+ def show(gene, index, mode, z, channel, n_min, n_max, n_gamma, t_min, t_max, t_gamma):
354
+ nucleus, target = (n_min, n_max, n_gamma), (t_min, t_max, t_gamma)
355
+ if mode == "3D":
356
+ stack, limits = viewer.load_sample_3d(gene, index)
357
+ return viewer.render(stack[int(z) - 1], channel, nucleus, target, limits)
358
  _, stack, _ = load(gene)
359
+ return viewer.render(stack[index], channel, nucleus, target)
360
 
361
+ def sample_file(gene, index, mode):
362
  """The raw sample under a readable name, copied where Gradio is allowed to serve it."""
363
+ if mode == "3D":
364
+ source, name = viewer.sample_file_3d(gene, index), f"virtual_opencell_3d_{gene}_{index + 1:04d}.tif"
365
+ else:
366
+ source, name = load(gene)[2][index], f"virtual_opencell_{gene}_{index + 1:04d}.tif"
367
+ path = os.path.join(tempfile.gettempdir(), name)
368
+ shutil.copyfile(source, path)
369
  return path
370
 
371
+ def protein_view(gene, mode, z, *settings):
372
  """Panel, thumbnails, first sample and its file for one protein."""
373
+ row = PROTEINS.loc[gene]
374
+ thumbs = viewer.thumbnails_3d(gene) if mode == "3D" else viewer.thumbnails(load(gene)[1])
375
+ image = show(gene, 0, mode, z, *settings)
376
+ return viewer.target_html(gene, row, mode == "3D"), thumbs, image, sample_file(gene, 0, mode)
377
 
378
  def umap_plot(gene):
379
  try:
 
382
  print(f"[virtual opencell] map unavailable: {exc}")
383
  return None
384
 
385
+ def select_protein(gene, mode, z, *settings):
386
  try:
387
+ info, thumbs, image, path = protein_view(gene, mode, z, *settings)
388
  except Exception as exc:
389
+ raise gr.Error(f"Could not load the {mode} images for {gene}: {viewer.load_error(exc)}") from exc
390
+ # there is no 3D map yet: leave the hidden one alone rather than redraw it
391
+ umap_update = gr.update() if mode == "3D" else umap_plot(gene)
392
+ return info, gr.Gallery(value=thumbs, selected_index=0), image, 0, path, umap_update
393
+
394
+ def select_mode(gene, mode, z, *settings):
395
+ """2D <-> 3D: the protein's other image set, the slice slider for 3D, the maps for 2D only."""
396
+ info, samples, image, index, path, _ = select_protein(gene, mode, z, *settings)
397
+ two_d = mode == "2D"
398
+ umap_update = gr.update(value=umap_plot(gene), visible=True) if two_d else gr.update(visible=False)
399
+ return (info, samples, image, index, path, umap_update, gr.update(visible=not two_d),
400
+ *[gr.update(visible=two_d)] * 3)
401
+
402
+ def select_sample(gene, mode, z, channel, n_min, n_max, n_gamma, t_min, t_max, t_gamma, evt: gr.SelectData):
403
+ image = show(gene, evt.index, mode, z, channel, n_min, n_max, n_gamma, t_min, t_max, t_gamma)
404
+ return image, evt.index, sample_file(gene, evt.index, mode)
405
+
406
+ # Shown on first load: the default protein in 2D, unless its images cannot be fetched
407
  defaults = ("Both", 0, 100, 1.0, 0, 100, 1.0)
408
+ slice0 = viewer.DEFAULT_SLICE_3D + 1
409
  try:
410
+ info0, thumbs0, image0, file0 = protein_view(viewer.DEFAULT_GENE, "2D", slice0, *defaults)
411
  except Exception as exc: # e.g. no access to the dataset; the error is shown in the panel
412
  print(f"[startup] virtual OpenCell preload failed: {exc}")
413
  info0, thumbs0, image0, file0 = viewer.error_html(viewer.DEFAULT_GENE, exc), None, None, None
 
429
  with gr.Row():
430
  with gr.Column(scale=4):
431
  info = gr.HTML(info0)
432
+ umap_header = gr.HTML(viewer.UMAP_HTML, padding=False)
433
  umap = gr.Plot(value=umap0, show_label=False, elem_classes="vo-umap")
434
 
435
  with gr.Column(scale=6):
436
  with gr.Row():
437
+ mode = gr.Radio(["2D", "3D"], value="2D", label="Virtual staining", scale=0,
438
+ min_width=170, elem_classes="vo-buttons")
439
  channel = gr.Radio(viewer.CHANNELS, value=defaults[0], label="Channel",
440
  elem_classes="vo-buttons")
441
  download = gr.DownloadButton("Download TIFF", value=file0, size="sm", variant="secondary",
 
444
  value=image0, show_label=False, interactive=False, height=600,
445
  show_download_button=True, elem_classes="vo-viewer",
446
  )
447
+ # OpenCell's "2D slice" view: one confocal slice of the stack, picked under the image
448
+ z = gr.Slider(1, viewer.SLICES_3D, value=slice0, step=1, visible=False,
449
+ label=f"Confocal slice (of {viewer.SLICES_3D}, centred on the nucleus)")
450
  gr.HTML('<div class="vo-settings-label">Samples</div>', padding=False)
451
  samples = gr.Gallery(
452
  value=thumbs0, selected_index=0 if thumbs0 else None, show_label=False,
 
467
 
468
  # the full map: every image and every protein named. One fixed figure (~2 MB) sent with the
469
  # page; the panel map above follows the selection, so this one never has to be re-sent
470
+ full_header = gr.HTML('<div class="vo-target" style="text-align: center; padding-right: 0"><div class="vo-section-header">Full virtual staining map</div>'
471
+ '<div class="vo-section-caption">All 93,510 generated images, each protein named at the '
472
+ 'centre of its images; zoom in to separate the names</div></div>', padding=False)
473
+ full_map = gr.Plot(value=full_map0, show_label=False, elem_classes="vo-umap-full")
474
 
475
  gr.HTML(viewer.FOOTER_HTML)
476
 
477
  index = gr.State(0)
478
  settings = [channel, n_min, n_max, n_gamma, t_min, t_max, t_gamma]
479
 
480
+ protein.change(select_protein, inputs=[protein, mode, z] + settings,
481
  outputs=[info, samples, image, index, download, umap])
482
+ mode.change(select_mode, inputs=[protein, mode, z] + settings,
483
+ outputs=[info, samples, image, index, download, umap, z, umap_header, full_header, full_map])
484
 
485
  # Redraws take milliseconds: no progress overlay, and a dragged slider only renders its latest value
486
+ samples.select(select_sample, inputs=[protein, mode, z] + settings, outputs=[image, index, download],
487
  show_progress="hidden")
488
+ for control in [z] + settings:
489
+ control.change(show, inputs=[protein, index, mode, z] + settings, outputs=image,
490
  show_progress="hidden", trigger_mode="always_last")
491
 
492
 
cell_fm/apps/virtual_opencell/viewer.py CHANGED
@@ -21,6 +21,15 @@ read them from disk instead.
21
 
22
  The map of all samples is opencell/vs_umap_embedding.npz in the weights repo: one UMAP point
23
  per sample (93,510, grouped by protein), embedded with opencell/vit.bin.
 
 
 
 
 
 
 
 
 
24
  """
25
 
26
  import html
@@ -32,10 +41,19 @@ import numpy as np
32
  import pandas as pd
33
  import plotly.graph_objects as go
34
  import tifffile
 
35
 
36
  DATASET_REPO = os.environ.get("VIRTUAL_OPENCELL_REPO", "BoHuangLab/CELL-FM")
37
  LOCAL_DIR = os.environ.get("VIRTUAL_OPENCELL_LOCAL_DIR", "")
38
  IMAGE_DIR = "OpenCell/virtual_staining"
 
 
 
 
 
 
 
 
39
 
40
  MODEL_REPO = os.environ.get("CELLFM_MODEL_REPO", "BoHuangLab/CELL-FM")
41
  UMAP_FILE = "opencell/vs_umap_embedding.npz"
@@ -106,28 +124,61 @@ def load_samples(gene: str, n_samples: int):
106
  return np.stack([tifffile.imread(p) for p in paths]), tuple(paths)
107
 
108
 
109
- def _scale(channel: np.ndarray, low: float, high: float, gamma: float) -> np.ndarray:
110
- """One channel to [0, 1]: auto-contrast, then OpenCell's intensity range (%) and gamma."""
111
- lo, hi = np.percentile(channel, AUTO_PERCENTILES)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
  x = (channel.astype(np.float32) - lo) / max(hi - lo, 1.0)
113
  low, high = low / 100.0, max(high, low + 1.0) / 100.0
114
  return np.clip((x - low) / (high - low), 0.0, 1.0) ** gamma
115
 
116
 
117
  def render(sample: np.ndarray, channel: str = "Both",
118
- nucleus=(0, 100, 1.0), target=(0, 100, 1.0)) -> np.ndarray:
119
- """A (2, H, W) sample as an (H, W, 3) uint8 image; nucleus/target are (min %, max %, gamma)."""
 
120
  rgb = np.zeros(sample.shape[1:] + (3,), dtype=np.float32)
121
  if channel in ("Nucleus", "Both"):
122
- rgb += _scale(sample[0], *nucleus)[..., None] * NUCLEUS_RGB
123
  if channel in ("Target", "Both"):
124
- rgb += _scale(sample[1], *target)[..., None] * TARGET_RGB
125
  return (np.clip(rgb, 0.0, 1.0) * 255).astype(np.uint8)
126
 
127
 
128
- def thumbnails(stack: np.ndarray) -> list:
129
  """Both channels at default settings, downsized for the sample strip."""
130
- step = max(stack.shape[-1] // THUMBNAIL_SIZE, 1)
131
  return [render(sample)[::step, ::step] for sample in stack]
132
 
133
 
@@ -227,7 +278,7 @@ def _metadata_item(label: str, value: str) -> str:
227
  f'<div class="vo-metadata-value">{value}</div></div>')
228
 
229
 
230
- def target_html(gene: str, row: pd.Series) -> str:
231
  """The left-hand panel of an OpenCell target page: name, identifiers, localization."""
232
  esc = html.escape
233
  uniprot, ensg, cid = row["uniprot_id"], row["ensg_id"], row["opencell_cid"]
@@ -258,7 +309,7 @@ def target_html(gene: str, row: pd.Series) -> str:
258
  <div class="vo-target">
259
  <div class="vo-target-name">{esc(gene)}</div>
260
  <div class="vo-protein-description">{esc(name)}</div>
261
- <div class="vo-status">CELL-FM virtual staining &middot; {int(row['n_samples'])} samples</div>
262
  <div class="vo-metadata">{''.join(items)}</div>
263
  <div class="vo-section-header">Protein localization</div>
264
  <div class="vo-section-caption">OpenCell's annotation of the real cell line; bar height is the grade</div>
 
21
 
22
  The map of all samples is opencell/vs_umap_embedding.npz in the weights repo: one UMAP point
23
  per sample (93,510, grouped by protein), embedded with opencell/vit.bin.
24
+
25
+ The 3D virtual staining (CELL-FM 3D) is in the same repo, 64 samples for every protein:
26
+
27
+ OpenCell/virtual_staining_3d/<GENE>/<NNNN>.tif (48, 2, 192, 192) uint16, z first
28
+ OpenCell/virtual_staining_3d_thumbnails/<GENE>.jpg
29
+
30
+ A stack is about 7 MB, so a protein's 64 are fetched one at a time, when opened, and the sample
31
+ strip comes from the thumbnail sheet (notebooks/tools/build_virtual_opencell_3d_thumbnails.py).
32
+ Stacks are shown one z slice at a time, like OpenCell's "2D slice" view. There is no 3D map yet.
33
  """
34
 
35
  import html
 
41
  import pandas as pd
42
  import plotly.graph_objects as go
43
  import tifffile
44
+ from PIL import Image
45
 
46
  DATASET_REPO = os.environ.get("VIRTUAL_OPENCELL_REPO", "BoHuangLab/CELL-FM")
47
  LOCAL_DIR = os.environ.get("VIRTUAL_OPENCELL_LOCAL_DIR", "")
48
  IMAGE_DIR = "OpenCell/virtual_staining"
49
+ IMAGE_DIR_3D = "OpenCell/virtual_staining_3d"
50
+ THUMBNAIL_DIR_3D = "OpenCell/virtual_staining_3d_thumbnails"
51
+ SAMPLES_3D = 64
52
+ SLICES_3D = 48
53
+ # Crops are centred on the nucleus' 3D centroid, so its centre slice is the default view
54
+ DEFAULT_SLICE_3D = SLICES_3D // 2
55
+ THUMBNAIL_SIZE_3D = 96
56
+ THUMBNAIL_COLUMNS_3D = 8
57
 
58
  MODEL_REPO = os.environ.get("CELLFM_MODEL_REPO", "BoHuangLab/CELL-FM")
59
  UMAP_FILE = "opencell/vs_umap_embedding.npz"
 
124
  return np.stack([tifffile.imread(p) for p in paths]), tuple(paths)
125
 
126
 
127
+ def _download(name: str) -> str:
128
+ if LOCAL_DIR:
129
+ return os.path.join(LOCAL_DIR, name)
130
+ from huggingface_hub import hf_hub_download
131
+
132
+ return hf_hub_download(repo_id=DATASET_REPO, filename=name, repo_type="dataset")
133
+
134
+
135
+ def sample_file_3d(gene: str, index: int) -> str:
136
+ """Local path of one 3D sample (0-based index), downloading it first unless reading from disk."""
137
+ return _download(f"{IMAGE_DIR_3D}/{gene}/{index + 1:04d}.tif")
138
+
139
+
140
+ @lru_cache(maxsize=32)
141
+ def load_sample_3d(gene: str, index: int):
142
+ """(stack, limits): stack is (Z, 2, H, W) uint16; limits are each channel's auto-contrast
143
+ window over the whole stack, so a slice keeps the same contrast as the others."""
144
+ stack = tifffile.imread(sample_file_3d(gene, index))
145
+ limits = tuple(tuple(np.percentile(stack[:, c], AUTO_PERCENTILES)) for c in range(stack.shape[1]))
146
+ return stack, limits
147
+
148
+
149
+ @lru_cache(maxsize=64)
150
+ def thumbnails_3d(gene: str) -> list:
151
+ """The sample strip of a protein's 3D stacks: its centre slices, cut from the thumbnail sheet."""
152
+ sheet = np.asarray(Image.open(_download(f"{THUMBNAIL_DIR_3D}/{gene}.jpg")).convert("RGB"))
153
+ size = THUMBNAIL_SIZE_3D
154
+ return [sheet[r:r + size, c:c + size] for r in range(0, sheet.shape[0], size)
155
+ for c in range(0, sheet.shape[1], size)]
156
+
157
+
158
+ def _scale(channel: np.ndarray, low: float, high: float, gamma: float, limits=None) -> np.ndarray:
159
+ """One channel to [0, 1]: auto-contrast (over `limits` if given, else this image's own
160
+ percentiles), then OpenCell's intensity range (%) and gamma."""
161
+ lo, hi = limits if limits is not None else np.percentile(channel, AUTO_PERCENTILES)
162
  x = (channel.astype(np.float32) - lo) / max(hi - lo, 1.0)
163
  low, high = low / 100.0, max(high, low + 1.0) / 100.0
164
  return np.clip((x - low) / (high - low), 0.0, 1.0) ** gamma
165
 
166
 
167
  def render(sample: np.ndarray, channel: str = "Both",
168
+ nucleus=(0, 100, 1.0), target=(0, 100, 1.0), limits=(None, None)) -> np.ndarray:
169
+ """A (2, H, W) sample as an (H, W, 3) uint8 image; nucleus/target are (min %, max %, gamma),
170
+ and limits each channel's auto-contrast window (None: from this image)."""
171
  rgb = np.zeros(sample.shape[1:] + (3,), dtype=np.float32)
172
  if channel in ("Nucleus", "Both"):
173
+ rgb += _scale(sample[0], *nucleus, limits=limits[0])[..., None] * NUCLEUS_RGB
174
  if channel in ("Target", "Both"):
175
+ rgb += _scale(sample[1], *target, limits=limits[1])[..., None] * TARGET_RGB
176
  return (np.clip(rgb, 0.0, 1.0) * 255).astype(np.uint8)
177
 
178
 
179
+ def thumbnails(stack: np.ndarray, size: int = THUMBNAIL_SIZE) -> list:
180
  """Both channels at default settings, downsized for the sample strip."""
181
+ step = max(stack.shape[-1] // size, 1)
182
  return [render(sample)[::step, ::step] for sample in stack]
183
 
184
 
 
278
  f'<div class="vo-metadata-value">{value}</div></div>')
279
 
280
 
281
+ def target_html(gene: str, row: pd.Series, three_d: bool = False) -> str:
282
  """The left-hand panel of an OpenCell target page: name, identifiers, localization."""
283
  esc = html.escape
284
  uniprot, ensg, cid = row["uniprot_id"], row["ensg_id"], row["opencell_cid"]
 
309
  <div class="vo-target">
310
  <div class="vo-target-name">{esc(gene)}</div>
311
  <div class="vo-protein-description">{esc(name)}</div>
312
+ <div class="vo-status">CELL-FM {"3D " if three_d else ""}virtual staining &middot; {SAMPLES_3D if three_d else int(row['n_samples'])} samples</div>
313
  <div class="vo-metadata">{''.join(items)}</div>
314
  <div class="vo-section-header">Protein localization</div>
315
  <div class="vo-section-caption">OpenCell's annotation of the real cell line; bar height is the grade</div>