GPUburnout commited on
Commit
89f54b2
·
verified ·
1 Parent(s): e8e8a06

Persistent client-side 3Dmol viewer: focus/grey/hide + in-place highlights (orientation preserved)

Browse files
Files changed (1) hide show
  1. app.py +154 -137
app.py CHANGED
@@ -17,10 +17,11 @@ CANDIDATES, not patent-ready antibodies — wet-lab validation still required.
17
  """
18
 
19
  import io
 
 
20
  import os
21
  import time
22
  import gradio as gr
23
- import py3Dmol
24
  from ImmuneBuilder import ABodyBuilder2
25
 
26
  try:
@@ -358,13 +359,6 @@ def format_flags(flags, h3_len, ok):
358
 
359
 
360
  # -------------------------------------------------------------------- viewer
361
- # Which chains the viewer draws, for the "Show chains" selector.
362
- CHAIN_VIEW = {
363
- "Both chains": ("H", "L"),
364
- "Heavy only (VH)": ("H",),
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"],
@@ -384,77 +378,145 @@ def build_track(chain_label: str, residues):
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
395
- for chain in chains:
396
- view.addStyle({"chain": chain}, {"cartoon": {"color": "#cfd8dc"}})
397
- for cdr, (lo, hi) in CDR_RANGES.items():
398
- view.addStyle(
399
- {"chain": chain, "resi": list(range(lo, hi + 1))},
400
- {"cartoon": {"color": CDR_COLORS[chain][cdr]}},
401
- )
402
- if highlights:
403
- pos = highlights.get(chain, [])
404
- if pos:
405
- view.addStyle(
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)
419
- html = view._make_html()
420
- esc = html.replace("&", "&").replace('"', """)
421
- return f'<iframe style="width:100%;height:580px;border:none;border-radius:8px" srcdoc="{esc}"></iframe>'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
422
 
423
 
424
  # ---------------------------------------------------------------------- fold
425
- def fold(heavy: str, light: str, chain_choice: str = "Both chains"):
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})
437
  dt = time.time() - t0
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)
446
- chains = CHAIN_VIEW.get(chain_choice, ("H", "L"))
447
- viewer = render_structure(pdb, spin=False, highlights=highlights, chains=chains)
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,58 +532,13 @@ 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
@@ -546,11 +563,17 @@ with gr.Blocks(title="MyAbs") as demo: # theme moved to launch() in Gradio 6
546
  status = gr.Markdown()
547
  pdb_file = gr.File(label="Download structure (.pdb)")
548
  with gr.Column(scale=3):
549
- chain_dd = gr.Radio(
550
- choices=list(CHAIN_VIEW.keys()), value="Both chains",
551
- label="Show chains", scale=1,
 
 
 
 
 
 
 
552
  )
553
- viewer = gr.HTML()
554
  gr.HTML(LEGEND_HTML)
555
  gr.Markdown(
556
  "**Rotate it yourself** — _Touchscreen:_ one-finger drag = rotate · "
@@ -570,41 +593,35 @@ with gr.Blocks(title="MyAbs") as demo: # theme moved to launch() in Gradio 6
570
  gr.Markdown("---")
571
  flags_md = gr.Markdown()
572
 
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
 
584
  # Pick a therapeutic -> load its sequences + provenance -> auto-fold.
585
  lib_dd.change(load_library, inputs=lib_dd, outputs=[h_in, l_in, provenance]).then(
586
- fold, inputs=[h_in, l_in, chain_dd], outputs=fold_outputs
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
 
17
  """
18
 
19
  import io
20
+ import itertools
21
+ import json
22
  import os
23
  import time
24
  import gradio as gr
 
25
  from ImmuneBuilder import ABodyBuilder2
26
 
27
  try:
 
359
 
360
 
361
  # -------------------------------------------------------------------- viewer
 
 
 
 
 
 
 
362
  # Per-residue colors for the clickable sequence tracks (CDRs reuse the 3D colors).
363
  TRACK_COLORS = {
364
  "H1": CDR_COLORS["H"]["1"], "H2": CDR_COLORS["H"]["2"], "H3": CDR_COLORS["H"]["3"],
 
378
  return tokens, idxmap
379
 
380
 
381
+ FOCUS_CHOICES = ["Both chains", "Heavy only (VH)", "Light only (VL)"]
382
+ MODE_CHOICES = ["Grey out others", "Hide others"]
383
+
384
+ _NONCE = itertools.count(1)
385
+
386
+
387
+ def build_payload(pdb: str, highlights) -> str:
388
+ """JSON handed to the client-side viewer: the structure + liability positions.
389
+ The nonce guarantees the value changes each fold so the .change bridge fires."""
390
+ highlights = highlights or {}
391
+ return json.dumps({
392
+ "pdb": pdb,
393
+ "highlights": {"H": highlights.get("H", []), "L": highlights.get("L", [])},
394
+ "n": next(_NONCE),
395
+ })
396
+
397
+
398
+ def toggle_payload(chain_label: str, evt, idxmap) -> str:
399
+ """JSON telling the client to toggle a lime stick on one clicked residue."""
400
+ idx = getattr(evt, "index", None)
401
+ if isinstance(idx, (list, tuple)):
402
+ idx = idx[0] if idx else None
403
+ imap = (idxmap or {}).get(chain_label, [])
404
+ imgt = imap[idx] if isinstance(idx, int) and 0 <= idx < len(imap) else None
405
+ return json.dumps({"chain": chain_label, "imgt": imgt, "n": next(_NONCE)})
406
+
407
+
408
+ # Client-side 3Dmol controller. One persistent viewer; every interaction updates
409
+ # it IN PLACE (no re-render, no zoomTo) so the camera / orientation is preserved.
410
+ # Loaded once via demo.load(js=...); it also injects 3Dmol.js from the CDN.
411
+ _CDR_JS = json.dumps(CDR_COLORS)
412
+ _RANGES_JS = json.dumps({k: list(v) for k, v in CDR_RANGES.items()})
413
+ CONTROLLER_JS = """
414
+ () => {
415
+ if (window.__myabsReady) return;
416
+ window.__myabsReady = true;
417
+ const CDR = __CDR__;
418
+ const RANGES = __RANGES__;
419
+ const rlist = (lo,hi) => { let a=[]; for(let i=lo;i<=hi;i++) a.push(i); return a; };
420
+ const S = window.myabsState = {viewer:null, highlights:{H:[],L:[]}, picks:{H:[],L:[]},
421
+ focus:["H","L"], mode:"grey", spinning:false};
422
+
423
+ window.myabsApply = () => {
424
+ const v = S.viewer; if(!v) return;
425
+ v.setStyle({}, {});
426
+ ["H","L"].forEach(ch => {
427
+ if (S.focus.includes(ch)) {
428
+ v.addStyle({chain:ch}, {cartoon:{color:"#cfd8dc"}});
429
+ for (const c in RANGES){ const r=RANGES[c];
430
+ v.addStyle({chain:ch, resi:rlist(r[0],r[1])}, {cartoon:{color:CDR[ch][c]}}); }
431
+ const hl=S.highlights[ch]||[]; if(hl.length) v.addStyle({chain:ch, resi:hl},{stick:{color:"magenta",radius:0.3}});
432
+ const pk=S.picks[ch]||[]; if(pk.length) v.addStyle({chain:ch, resi:pk},{stick:{color:"lime",radius:0.3}});
433
+ } else if (S.mode === "grey") {
434
+ v.addStyle({chain:ch}, {cartoon:{color:"#c7ccd1", opacity:0.35}});
435
+ }
436
+ });
437
+ v.render();
438
+ };
439
+
440
+ window.myabsInit = () => {
441
+ if (S.viewer) return true;
442
+ const el = document.getElementById("myabs-viewer");
443
+ if (!el || !window.$3Dmol) return false;
444
+ S.viewer = $3Dmol.createViewer(el, {backgroundColor:"white"});
445
+ return true;
446
+ };
447
+
448
+ window.myabsLoad = (payload) => {
449
+ if (!payload) return;
450
+ let p; try { p = JSON.parse(payload); } catch(e){ return; }
451
+ if (!p.pdb) return;
452
+ if (!window.myabsInit()) { setTimeout(()=>window.myabsLoad(payload), 200); return; }
453
+ const v = S.viewer;
454
+ S.highlights = p.highlights || {H:[],L:[]}; S.picks = {H:[],L:[]}; S.spinning = false;
455
+ v.removeAllModels(); v.addModel(p.pdb, "pdb");
456
+ window.myabsApply();
457
+ v.zoomTo(); v.render(); // new molecule: recentering here is expected
458
+ };
459
+
460
+ window.myabsSetFocus = (choice) => {
461
+ const M = {"Both chains":["H","L"], "Heavy only (VH)":["H"], "Light only (VL)":["L"]};
462
+ S.focus = M[choice] || ["H","L"]; window.myabsApply(); // no zoomTo -> view kept
463
+ };
464
+ window.myabsSetMode = (m) => {
465
+ S.mode = (m && m.indexOf("Hide")>=0) ? "hide" : "grey"; window.myabsApply();
466
+ };
467
+ window.myabsToggleResidue = (payload) => {
468
+ let p; try { p = JSON.parse(payload); } catch(e){ return; }
469
+ if (p.imgt == null || !p.chain) return;
470
+ const arr = S.picks[p.chain] || (S.picks[p.chain]=[]);
471
+ const i = arr.indexOf(p.imgt); if (i>=0) arr.splice(i,1); else arr.push(p.imgt);
472
+ window.myabsApply();
473
+ };
474
+ window.myabsClear = () => { S.picks = {H:[],L:[]}; window.myabsApply(); };
475
+ window.myabsToggleSpin = () => {
476
+ const v = S.viewer; if (!v) return "▶ Spin";
477
+ S.spinning = !S.spinning; v.spin(S.spinning ? "y" : false);
478
+ return S.spinning ? "⏸ Stop" : "▶ Spin";
479
+ };
480
+
481
+ if (!window.$3Dmol) {
482
+ const s = document.createElement("script");
483
+ s.src = "https://3Dmol.org/build/3Dmol-min.js";
484
+ s.onload = () => window.myabsInit();
485
+ document.head.appendChild(s);
486
+ } else { window.myabsInit(); }
487
+ }
488
+ """.replace("__CDR__", _CDR_JS).replace("__RANGES__", _RANGES_JS)
489
 
490
 
491
  # ---------------------------------------------------------------------- fold
492
+ def fold(heavy: str, light: str):
493
+ """Fold VH+VL, scan liabilities, and hand the structure to the client viewer."""
494
  heavy, light = clean(heavy), clean(light)
495
  reset_btn = gr.update(value="▶ Spin")
 
496
  if not heavy or not light:
497
+ return ("Enter both a heavy and a light chain.", "", None, "",
498
+ [], [], {"H": [], "L": []}, reset_btn)
 
499
  try:
500
  t0 = time.time()
501
  antibody = PREDICTOR.predict({"H": heavy, "L": light})
502
  dt = time.time() - t0
503
  antibody.save("myabs_fold.pdb")
504
  except Exception as e: # OpenMM refinement / numbering can occasionally fail
505
+ return (f"Fold failed: {e}", "", None, "",
506
+ [], [], {"H": [], "L": []}, reset_btn)
 
507
 
508
  pdb = open("myabs_fold.pdb").read()
509
  flags, highlights, h3_len, ok = developability(heavy, light, pdb)
 
 
510
  status = (f"Folded in {dt:.1f} s · VH {len(heavy)} aa / VL {len(light)} aa · "
511
  f"CDRs highlighted (H: yellow/orange/red, L: cyan/blue).")
512
  flags_md = format_flags(flags, h3_len, ok)
513
 
 
514
  h_tokens, h_idx = build_track("H", number_chain(heavy) or [])
515
  l_tokens, l_idx = build_track("L", number_chain(light) or [])
516
  idxmap = {"H": h_idx, "L": l_idx}
517
+ payload = build_payload(pdb, highlights) # -> client-side viewer via .change bridge
518
+ return (status, flags_md, "myabs_fold.pdb", payload,
519
+ h_tokens, l_tokens, idxmap, reset_btn)
520
 
521
 
522
  def load_library(name: str):
 
532
  return entry["H"], entry["L"], prov
533
 
534
 
535
+ def click_heavy(idxmap, evt: gr.SelectData):
536
+ """Map a heavy-chain track click to its IMGT position for the client to toggle."""
537
+ return toggle_payload("H", evt, idxmap)
 
 
 
 
 
538
 
539
 
540
+ def click_light(idxmap, evt: gr.SelectData):
541
+ return toggle_payload("L", evt, idxmap)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
542
 
543
 
544
  with gr.Blocks(title="MyAbs") as demo: # theme moved to launch() in Gradio 6
 
563
  status = gr.Markdown()
564
  pdb_file = gr.File(label="Download structure (.pdb)")
565
  with gr.Column(scale=3):
566
+ with gr.Row():
567
+ focus_dd = gr.Radio(choices=FOCUS_CHOICES, value="Both chains",
568
+ label="Focus chain")
569
+ mode_dd = gr.Radio(choices=MODE_CHOICES, value="Grey out others",
570
+ label="The non-focused chain is…")
571
+ # Persistent 3Dmol viewer container. Updated in place by the client-side
572
+ # controller (CONTROLLER_JS) so interactions never reset the camera.
573
+ viewer = gr.HTML(
574
+ '<div id="myabs-viewer" style="width:100%;height:580px;position:relative;'
575
+ 'border:1px solid #eee;border-radius:8px"></div>'
576
  )
 
577
  gr.HTML(LEGEND_HTML)
578
  gr.Markdown(
579
  "**Rotate it yourself** — _Touchscreen:_ one-finger drag = rotate · "
 
593
  gr.Markdown("---")
594
  flags_md = gr.Markdown()
595
 
596
+ idxmap_state = gr.State({"H": [], "L": []}) # track index -> imgt, per chain
597
+ # Hidden bridges: server writes JSON here, a .change(js=...) hands it to the viewer.
598
+ load_box = gr.Textbox(visible=False) # fold -> myabsLoad
599
+ toggle_box = gr.Textbox(visible=False) # residue click -> myabsToggleResidue
 
600
 
601
+ fold_outputs = [status, flags_md, pdb_file, load_box, h_track, l_track,
602
+ idxmap_state, spin_btn]
603
 
604
+ go.click(fold, inputs=[h_in, l_in], outputs=fold_outputs)
605
 
606
  # Pick a therapeutic -> load its sequences + provenance -> auto-fold.
607
  lib_dd.change(load_library, inputs=lib_dd, outputs=[h_in, l_in, provenance]).then(
608
+ fold, inputs=[h_in, l_in], outputs=fold_outputs
609
  )
610
 
611
+ # --- client-side viewer controls (no server round-trip, camera preserved) ---
612
+ load_box.change(None, inputs=[load_box], js="(p) => window.myabsLoad(p)")
613
+ toggle_box.change(None, inputs=[toggle_box], js="(p) => window.myabsToggleResidue(p)")
614
+ focus_dd.change(None, inputs=[focus_dd], js="(c) => window.myabsSetFocus(c)")
615
+ mode_dd.change(None, inputs=[mode_dd], js="(m) => window.myabsSetMode(m)")
616
+ spin_btn.click(None, outputs=[spin_btn], js="() => window.myabsToggleSpin()")
617
+ clear_btn.click(None, js="() => window.myabsClear()")
618
 
619
+ # Residue click: server maps track index -> IMGT, client applies the lime stick.
620
+ h_track.select(click_heavy, inputs=[idxmap_state], outputs=[toggle_box])
621
+ l_track.select(click_light, inputs=[idxmap_state], outputs=[toggle_box])
 
 
622
 
623
+ # Load 3Dmol.js + define the controller once, on page load.
624
+ demo.load(None, js=CONTROLLER_JS)
 
 
 
 
 
 
625
 
626
  if __name__ == "__main__":
627
  # Local dev defaults to 127.0.0.1:7900. On an HF Docker Space the Dockerfile