Spaces:
Sleeping
Sleeping
Add clickable residue tracks (highlight AAs in 3D)
Browse files
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("
|
|
|
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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,
|
|
|
|
| 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).
|