GPUburnout commited on
Commit
e8e8a06
·
verified ·
1 Parent(s): 4b9d07e

Add clickable residue tracks (highlight AAs in 3D)

Browse files
Files changed (1) hide show
  1. app.py +106 -14
app.py CHANGED
@@ -109,7 +109,8 @@ LEGEND_HTML = (
109
  + _swatch("CDR-H1", CDR_COLORS["H"]["1"]) + _swatch("H2", CDR_COLORS["H"]["2"])
110
  + _swatch("H3", CDR_COLORS["H"]["3"]) + _swatch("CDR-L1", CDR_COLORS["L"]["1"])
111
  + _swatch("L2", CDR_COLORS["L"]["2"]) + _swatch("L3", CDR_COLORS["L"]["3"])
112
- + _swatch("liability", "magenta") + _swatch("framework", "#cfd8dc")
 
113
  + "</div>"
114
  )
115
 
@@ -364,12 +365,30 @@ CHAIN_VIEW = {
364
  "Light only (VL)": ("L",),
365
  }
366
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
367
 
368
  def render_structure(pdb: str, spin: bool = False, highlights=None,
369
- chains=("H", "L")) -> str:
370
  """py3Dmol view -> self-contained HTML in an iframe (renders inside Gradio).
371
- Framework grey, CDR loops colored, liability residues as magenta sticks.
372
- Only the chains in `chains` are drawn; the rest are hidden."""
373
  view = py3Dmol.view(width=760, height=560)
374
  view.addModel(pdb, "pdb")
375
  view.setStyle({}, {}) # hide everything; only the selected chains get drawn below
@@ -387,6 +406,13 @@ def render_structure(pdb: str, spin: bool = False, highlights=None,
387
  {"chain": chain, "resi": pos},
388
  {"stick": {"color": "magenta", "radius": 0.3}},
389
  )
 
 
 
 
 
 
 
390
  view.zoomTo({"chain": list(chains)}) # re-center on the visible chain(s)
391
  if spin:
392
  view.spin(True)
@@ -400,9 +426,11 @@ def fold(heavy: str, light: str, chain_choice: str = "Both chains"):
400
  """Fold VH+VL, scan liabilities, render, and reset the spin toggle."""
401
  heavy, light = clean(heavy), clean(light)
402
  reset_btn = gr.update(value="▶ Spin")
 
403
  if not heavy or not light:
404
  return ("<p style='padding:1em'>Enter both a heavy and a light chain.</p>",
405
- "Need both chains.", "", None, "", {}, False, reset_btn)
 
406
  try:
407
  t0 = time.time()
408
  antibody = PREDICTOR.predict({"H": heavy, "L": light})
@@ -410,7 +438,8 @@ def fold(heavy: str, light: str, chain_choice: str = "Both chains"):
410
  antibody.save("myabs_fold.pdb")
411
  except Exception as e: # OpenMM refinement / numbering can occasionally fail
412
  return (f"<p style='padding:1em;color:#c00'>Fold failed: {e}</p>",
413
- f"Error: {e}", "", None, "", {}, False, reset_btn)
 
414
 
415
  pdb = open("myabs_fold.pdb").read()
416
  flags, highlights, h3_len, ok = developability(heavy, light, pdb)
@@ -419,7 +448,13 @@ def fold(heavy: str, light: str, chain_choice: str = "Both chains"):
419
  status = (f"Folded in {dt:.1f} s · VH {len(heavy)} aa / VL {len(light)} aa · "
420
  f"CDRs highlighted (H: yellow/orange/red, L: cyan/blue).")
421
  flags_md = format_flags(flags, h3_len, ok)
422
- return viewer, status, flags_md, "myabs_fold.pdb", pdb, highlights, False, reset_btn
 
 
 
 
 
 
423
 
424
 
425
  def load_library(name: str):
@@ -435,22 +470,58 @@ def load_library(name: str):
435
  return entry["H"], entry["L"], prov
436
 
437
 
438
- def toggle_spin(spin: bool, pdb: str, highlights, chain_choice: str = "Both chains"):
439
  """Flip spin on/off and re-render the stored structure (no re-fold)."""
440
  spin = not spin
441
  label = "⏸ Stop" if spin else "▶ Spin"
442
  if not pdb:
443
  return spin, gr.update(value=label), gr.update()
444
  chains = CHAIN_VIEW.get(chain_choice, ("H", "L"))
445
- return spin, gr.update(value=label), render_structure(pdb, spin, highlights, chains)
446
 
447
 
448
- def select_chains(chain_choice: str, pdb: str, highlights, spin: bool):
449
  """Re-render the stored structure showing only the chosen chain(s), no re-fold."""
450
  if not pdb:
451
  return gr.update()
452
  chains = CHAIN_VIEW.get(chain_choice, ("H", "L"))
453
- return render_structure(pdb, spin, highlights, chains)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
454
 
455
 
456
  with gr.Blocks(title="MyAbs") as demo: # theme moved to launch() in Gradio 6
@@ -486,6 +557,15 @@ with gr.Blocks(title="MyAbs") as demo: # theme moved to launch() in Gradio 6
486
  "pinch = zoom · two-finger drag = pan. _Mouse:_ drag = rotate · "
487
  "scroll = zoom · right-drag = pan."
488
  )
 
 
 
 
 
 
 
 
 
489
 
490
  gr.Markdown("---")
491
  flags_md = gr.Markdown()
@@ -493,8 +573,11 @@ with gr.Blocks(title="MyAbs") as demo: # theme moved to launch() in Gradio 6
493
  pdb_state = gr.State("") # last folded PDB, for re-render without re-folding
494
  hi_state = gr.State({}) # liability highlight positions by chain
495
  spin_state = gr.State(False) # is the structure currently spinning?
 
 
496
 
497
- fold_outputs = [viewer, status, flags_md, pdb_file, pdb_state, hi_state, spin_state, spin_btn]
 
498
 
499
  go.click(fold, inputs=[h_in, l_in, chain_dd], outputs=fold_outputs)
500
 
@@ -504,16 +587,25 @@ with gr.Blocks(title="MyAbs") as demo: # theme moved to launch() in Gradio 6
504
  )
505
 
506
  spin_btn.click(
507
- toggle_spin, inputs=[spin_state, pdb_state, hi_state, chain_dd],
508
  outputs=[spin_state, spin_btn, viewer],
509
  )
510
 
511
  # Switch which chain(s) are shown, re-rendering the stored fold (no re-fold).
512
  chain_dd.change(
513
- select_chains, inputs=[chain_dd, pdb_state, hi_state, spin_state],
514
  outputs=viewer,
515
  )
516
 
 
 
 
 
 
 
 
 
 
517
  if __name__ == "__main__":
518
  # Local dev defaults to 127.0.0.1:7900. On an HF Docker Space the Dockerfile
519
  # sets GRADIO_SERVER_NAME=0.0.0.0 and GRADIO_SERVER_PORT=7860 (app_port).
 
109
  + _swatch("CDR-H1", CDR_COLORS["H"]["1"]) + _swatch("H2", CDR_COLORS["H"]["2"])
110
  + _swatch("H3", CDR_COLORS["H"]["3"]) + _swatch("CDR-L1", CDR_COLORS["L"]["1"])
111
  + _swatch("L2", CDR_COLORS["L"]["2"]) + _swatch("L3", CDR_COLORS["L"]["3"])
112
+ + _swatch("liability", "magenta") + _swatch("your pick", "lime")
113
+ + _swatch("framework", "#cfd8dc")
114
  + "</div>"
115
  )
116
 
 
365
  "Light only (VL)": ("L",),
366
  }
367
 
368
+ # Per-residue colors for the clickable sequence tracks (CDRs reuse the 3D colors).
369
+ TRACK_COLORS = {
370
+ "H1": CDR_COLORS["H"]["1"], "H2": CDR_COLORS["H"]["2"], "H3": CDR_COLORS["H"]["3"],
371
+ "L1": CDR_COLORS["L"]["1"], "L2": CDR_COLORS["L"]["2"], "L3": CDR_COLORS["L"]["3"],
372
+ "fw": "#eceff1",
373
+ }
374
+
375
+
376
+ def build_track(chain_label: str, residues):
377
+ """Numbered residues -> (HighlightedText tokens, index->imgt map).
378
+ Each residue is its own token (combine_adjacent=False) so it clicks individually."""
379
+ tokens, idxmap = [], []
380
+ for imgt, _ins, aa in residues:
381
+ cdr = cdr_of(imgt)
382
+ tokens.append((aa, f"{chain_label}{cdr}" if cdr else "fw"))
383
+ idxmap.append(imgt)
384
+ return tokens, idxmap
385
+
386
 
387
  def render_structure(pdb: str, spin: bool = False, highlights=None,
388
+ chains=("H", "L"), manual=None) -> str:
389
  """py3Dmol view -> self-contained HTML in an iframe (renders inside Gradio).
390
+ Framework grey, CDR loops colored, liability residues as magenta sticks,
391
+ user-clicked residues as lime sticks. Only chains in `chains` are drawn."""
392
  view = py3Dmol.view(width=760, height=560)
393
  view.addModel(pdb, "pdb")
394
  view.setStyle({}, {}) # hide everything; only the selected chains get drawn below
 
406
  {"chain": chain, "resi": pos},
407
  {"stick": {"color": "magenta", "radius": 0.3}},
408
  )
409
+ if manual: # user-clicked residues, drawn last so they win over liability sticks
410
+ mpos = manual.get(chain, [])
411
+ if mpos:
412
+ view.addStyle(
413
+ {"chain": chain, "resi": mpos},
414
+ {"stick": {"color": "lime", "radius": 0.3}},
415
+ )
416
  view.zoomTo({"chain": list(chains)}) # re-center on the visible chain(s)
417
  if spin:
418
  view.spin(True)
 
426
  """Fold VH+VL, scan liabilities, render, and reset the spin toggle."""
427
  heavy, light = clean(heavy), clean(light)
428
  reset_btn = gr.update(value="▶ Spin")
429
+ empty_manual = {"H": [], "L": []}
430
  if not heavy or not light:
431
  return ("<p style='padding:1em'>Enter both a heavy and a light chain.</p>",
432
+ "Need both chains.", "", None, "", {}, False, reset_btn,
433
+ [], [], {"H": [], "L": []}, empty_manual)
434
  try:
435
  t0 = time.time()
436
  antibody = PREDICTOR.predict({"H": heavy, "L": light})
 
438
  antibody.save("myabs_fold.pdb")
439
  except Exception as e: # OpenMM refinement / numbering can occasionally fail
440
  return (f"<p style='padding:1em;color:#c00'>Fold failed: {e}</p>",
441
+ f"Error: {e}", "", None, "", {}, False, reset_btn,
442
+ [], [], {"H": [], "L": []}, empty_manual)
443
 
444
  pdb = open("myabs_fold.pdb").read()
445
  flags, highlights, h3_len, ok = developability(heavy, light, pdb)
 
448
  status = (f"Folded in {dt:.1f} s · VH {len(heavy)} aa / VL {len(light)} aa · "
449
  f"CDRs highlighted (H: yellow/orange/red, L: cyan/blue).")
450
  flags_md = format_flags(flags, h3_len, ok)
451
+
452
+ # Clickable sequence tracks (index->imgt), and reset manual highlights.
453
+ h_tokens, h_idx = build_track("H", number_chain(heavy) or [])
454
+ l_tokens, l_idx = build_track("L", number_chain(light) or [])
455
+ idxmap = {"H": h_idx, "L": l_idx}
456
+ return (viewer, status, flags_md, "myabs_fold.pdb", pdb, highlights, False, reset_btn,
457
+ h_tokens, l_tokens, idxmap, empty_manual)
458
 
459
 
460
  def load_library(name: str):
 
470
  return entry["H"], entry["L"], prov
471
 
472
 
473
+ def toggle_spin(spin: bool, pdb: str, highlights, chain_choice="Both chains", manual=None):
474
  """Flip spin on/off and re-render the stored structure (no re-fold)."""
475
  spin = not spin
476
  label = "⏸ Stop" if spin else "▶ Spin"
477
  if not pdb:
478
  return spin, gr.update(value=label), gr.update()
479
  chains = CHAIN_VIEW.get(chain_choice, ("H", "L"))
480
+ return spin, gr.update(value=label), render_structure(pdb, spin, highlights, chains, manual)
481
 
482
 
483
+ def select_chains(chain_choice: str, pdb: str, highlights, spin: bool, manual=None):
484
  """Re-render the stored structure showing only the chosen chain(s), no re-fold."""
485
  if not pdb:
486
  return gr.update()
487
  chains = CHAIN_VIEW.get(chain_choice, ("H", "L"))
488
+ return render_structure(pdb, spin, highlights, chains, manual)
489
+
490
+
491
+ def _rerender(pdb, highlights, spin, chain_choice, manual):
492
+ chains = CHAIN_VIEW.get(chain_choice, ("H", "L"))
493
+ return render_structure(pdb, spin, highlights, chains, manual)
494
+
495
+
496
+ def click_residue(chain_label, evt, idxmap, manual, pdb, highlights, spin, chain_choice):
497
+ """Toggle the clicked residue's highlight and re-render (no re-fold)."""
498
+ manual = {"H": list(manual.get("H", [])), "L": list(manual.get("L", []))}
499
+ idx = getattr(evt, "index", None)
500
+ if isinstance(idx, (list, tuple)):
501
+ idx = idx[0] if idx else None
502
+ imap = idxmap.get(chain_label, []) if idxmap else []
503
+ if not pdb or idx is None or not isinstance(idx, int) or idx < 0 or idx >= len(imap):
504
+ return manual, gr.update()
505
+ imgt = imap[idx]
506
+ cur = manual[chain_label]
507
+ cur.remove(imgt) if imgt in cur else cur.append(imgt)
508
+ return manual, _rerender(pdb, highlights, spin, chain_choice, manual)
509
+
510
+
511
+ def click_heavy(idxmap, manual, pdb, highlights, spin, chain_choice, evt: gr.SelectData):
512
+ return click_residue("H", evt, idxmap, manual, pdb, highlights, spin, chain_choice)
513
+
514
+
515
+ def click_light(idxmap, manual, pdb, highlights, spin, chain_choice, evt: gr.SelectData):
516
+ return click_residue("L", evt, idxmap, manual, pdb, highlights, spin, chain_choice)
517
+
518
+
519
+ def clear_highlights(pdb, highlights, spin, chain_choice):
520
+ """Drop all user-clicked residue highlights and re-render."""
521
+ manual = {"H": [], "L": []}
522
+ if not pdb:
523
+ return manual, gr.update()
524
+ return manual, _rerender(pdb, highlights, spin, chain_choice, manual)
525
 
526
 
527
  with gr.Blocks(title="MyAbs") as demo: # theme moved to launch() in Gradio 6
 
557
  "pinch = zoom · two-finger drag = pan. _Mouse:_ drag = rotate · "
558
  "scroll = zoom · right-drag = pan."
559
  )
560
+ gr.Markdown(
561
+ "**Click a residue below to highlight it on the structure (lime stick).** "
562
+ "Click it again to remove it. CDR residues are pre-colored."
563
+ )
564
+ h_track = gr.HighlightedText(label="Heavy chain (VH)", combine_adjacent=False,
565
+ show_legend=False, color_map=TRACK_COLORS)
566
+ l_track = gr.HighlightedText(label="Light chain (VL)", combine_adjacent=False,
567
+ show_legend=False, color_map=TRACK_COLORS)
568
+ clear_btn = gr.Button("Clear clicked highlights", size="sm")
569
 
570
  gr.Markdown("---")
571
  flags_md = gr.Markdown()
 
573
  pdb_state = gr.State("") # last folded PDB, for re-render without re-folding
574
  hi_state = gr.State({}) # liability highlight positions by chain
575
  spin_state = gr.State(False) # is the structure currently spinning?
576
+ idxmap_state = gr.State({"H": [], "L": []}) # track index -> imgt, per chain
577
+ manual_state = gr.State({"H": [], "L": []}) # user-clicked residues, per chain
578
 
579
+ fold_outputs = [viewer, status, flags_md, pdb_file, pdb_state, hi_state, spin_state,
580
+ spin_btn, h_track, l_track, idxmap_state, manual_state]
581
 
582
  go.click(fold, inputs=[h_in, l_in, chain_dd], outputs=fold_outputs)
583
 
 
587
  )
588
 
589
  spin_btn.click(
590
+ toggle_spin, inputs=[spin_state, pdb_state, hi_state, chain_dd, manual_state],
591
  outputs=[spin_state, spin_btn, viewer],
592
  )
593
 
594
  # Switch which chain(s) are shown, re-rendering the stored fold (no re-fold).
595
  chain_dd.change(
596
+ select_chains, inputs=[chain_dd, pdb_state, hi_state, spin_state, manual_state],
597
  outputs=viewer,
598
  )
599
 
600
+ # Click a residue in a sequence track -> toggle its lime highlight on the structure.
601
+ click_inputs = [idxmap_state, manual_state, pdb_state, hi_state, spin_state, chain_dd]
602
+ h_track.select(click_heavy, inputs=click_inputs, outputs=[manual_state, viewer])
603
+ l_track.select(click_light, inputs=click_inputs, outputs=[manual_state, viewer])
604
+ clear_btn.click(
605
+ clear_highlights, inputs=[pdb_state, hi_state, spin_state, chain_dd],
606
+ outputs=[manual_state, viewer],
607
+ )
608
+
609
  if __name__ == "__main__":
610
  # Local dev defaults to 127.0.0.1:7900. On an HF Docker Space the Dockerfile
611
  # sets GRADIO_SERVER_NAME=0.0.0.0 and GRADIO_SERVER_PORT=7860 (app_port).