Download app.py from arvban/bindscout: direct link, hf CLI and curl.
- Browser
- Download file 33.4 kB
-
https://huggingface.co/spaces/arvban/bindscout/resolve/main/app.py
- Command line
-
hf download hf://spaces/arvban/bindscout/app.py
-
curl -L -o app.py https://huggingface.co/spaces/arvban/bindscout/resolve/main/app.py
33.4 kB
| """Gradio front end for BindScout, the BindCraft patch-coverage targetability GNN. | |
| Upload a PDB (or give an RCSB id), get the same structure back with the per-residue | |
| targetability in the B-factor column, an NGL view coloured by that value, and the scored | |
| PDB and per-residue CSV to download. | |
| Hovering a residue reports its targetability and marks the 7 A patch the value is a | |
| statement about -- the training label is patch coverage, so the number belongs to the | |
| neighbourhood, not to the residue alone. | |
| """ | |
| # ZeroGPU -- and this import MUST come first, above torch and above anything that | |
| # imports it. `spaces` patches torch's CUDA layer, and it refuses to load once CUDA has | |
| # been initialised: "CUDA has been initialized before importing the `spaces` package". | |
| # Merely calling torch.cuda.is_available() counts, which is what TargetabilityPredictor's | |
| # constructor does -- so creating the predictor before this line silently disables the | |
| # accelerator, and ZeroGPU then rejects the whole Space for exposing no GPU function. | |
| # | |
| # The try/except covers the two non-ZeroGPU deployments, in both of which `spaces` must | |
| # end up as None: on a laptop it is not installed (ImportError), and on a CPU Space it | |
| # IS installed -- HF preinstalls it everywhere -- but explodes reading | |
| # torch.version.cuda.startswith(...) against a +cpu wheel, where that attribute is None. | |
| try: | |
| import spaces | |
| except Exception as _spaces_exc: # never let an optional accelerator break startup | |
| spaces = None | |
| _spaces_error = f"{type(_spaces_exc).__name__}: {_spaces_exc}" | |
| else: | |
| _spaces_error = None | |
| import html | |
| import json | |
| import os | |
| import re | |
| import tempfile | |
| import traceback | |
| import urllib.error | |
| import urllib.request | |
| import gradio as gr | |
| import torch as pt | |
| from inference import MAX_ATOMS, TargetabilityPredictor | |
| from patches import compute_patches | |
| # load once, at import, so the first request is not also the first model load | |
| PREDICTOR = TargetabilityPredictor() | |
| if spaces is not None: | |
| # ZeroGPU requires at least one @spaces.GPU function to exist at startup; with none | |
| # it refuses to serve at all, however healthy the app is. No CUDA device exists at | |
| # import time there -- only inside the decorated call, which is why .to("cuda") | |
| # lives in _run_model rather than here. | |
| # duration is a RESERVATION, and ZeroGPU quota is charged against it rather than | |
| # against the compute actually used -- so an over-generous value burns a viewer's | |
| # daily allowance many times faster than the work warrants. Inference is ~2 s for a | |
| # 200-residue target and well under a minute at the 20k-atom cap, so 60 s is ample. | |
| # At 180 s a full day's quota went in about a dozen clicks. | |
| gpu_task = spaces.GPU(duration=60) | |
| print("[bindscout] ZeroGPU entry point registered (60 s reservation)") | |
| else: | |
| print(f"[bindscout] running on CPU ({_spaces_error or 'spaces not installed'})") | |
| def gpu_task(fn): | |
| return fn | |
| def _run_model(pdb_path, chain_list, out_pdb): | |
| if pt.cuda.is_available() and PREDICTOR.device.type != "cuda": | |
| PREDICTOR.to("cuda") | |
| return PREDICTOR.predict(pdb_path, chains=chain_list or None, out_filepath=out_pdb) | |
| RCSB_URL = "https://files.rcsb.org/download/{}.pdb" | |
| PDB_ID_RE = re.compile(r"^[0-9][A-Za-z0-9]{3}$") | |
| # Sequential ramp, neutral grey -> dark red: light = low targetability, dark = high. | |
| # One hue with a neutral floor, so near-zero recedes and only real signal reads as | |
| # colour -- the whole surface is near-zero on most targets. The floor is a mid grey | |
| # rather than near-white: at #eceae7 the protein read as white and the low end was | |
| # indistinguishable from the page behind it. | |
| RAMP = [ | |
| "#c9c7c2", "#c9bcb4", "#c9b1a5", "#c9a595", "#c89886", "#c68a76", | |
| "#c17b65", "#ba6b55", "#b05a45", "#a34836", "#943628", "#82231a", "#6d0f0c", | |
| ] | |
| # residues the model never saw (waters, unselected chains) sit off the ramp entirely | |
| UNSCORED_COLOR = "#b9b7b2" | |
| # the patch highlight, deliberately off-ramp so it cannot be read as a targetability value | |
| PATCH_COLOR = "#1c5cab" | |
| # the hovered residue itself -- the centre the patch is defined around. A deeper | |
| # step of the same hue, so it reads as one of the patch rather than a second thing. | |
| CENTRE_COLOR = "#0d366b" | |
| # --------------------------------------------------------------------------- | |
| # input resolution | |
| # --------------------------------------------------------------------------- | |
| def _resolve_input(pdb_file, pdb_id, workdir): | |
| """Return a local path to score, or raise gr.Error with something actionable.""" | |
| if pdb_file is not None: | |
| src = pdb_file if isinstance(pdb_file, str) else pdb_file.name | |
| ext = os.path.splitext(src)[1].lower() | |
| if ext not in (".pdb", ".ent", ".cif", ".mmcif"): | |
| raise gr.Error(f"unsupported file type '{ext}' -- upload a .pdb, .ent or .cif") | |
| dst = os.path.join(workdir, os.path.basename(src)) | |
| with open(src, "rb") as fi, open(dst, "wb") as fo: | |
| fo.write(fi.read()) | |
| return dst, os.path.splitext(os.path.basename(src))[0] | |
| pdb_id = (pdb_id or "").strip() | |
| if not pdb_id: | |
| raise gr.Error("upload a structure file, or enter a 4-character RCSB PDB id") | |
| if not PDB_ID_RE.match(pdb_id): | |
| raise gr.Error(f"'{pdb_id}' is not a 4-character PDB id (e.g. 1M47)") | |
| dst = os.path.join(workdir, f"{pdb_id.upper()}.pdb") | |
| try: | |
| with urllib.request.urlopen(RCSB_URL.format(pdb_id.upper()), timeout=30) as r: | |
| data = r.read() | |
| except urllib.error.HTTPError as e: | |
| if e.code == 404: | |
| raise gr.Error(f"RCSB has no entry {pdb_id.upper()}") | |
| raise gr.Error(f"could not fetch {pdb_id.upper()} from RCSB ({e.code})") | |
| except Exception as e: | |
| raise gr.Error(f"could not reach RCSB: {e}") | |
| with open(dst, "wb") as fo: | |
| fo.write(data) | |
| return dst, pdb_id.upper() | |
| # --------------------------------------------------------------------------- | |
| # rendering | |
| # --------------------------------------------------------------------------- | |
| def _ramp_color(v): | |
| """Sequential ramp lookup for a 0-1 score, linear between the 13 published steps.""" | |
| if v is None or v < 0.0: | |
| return UNSCORED_COLOR | |
| v = min(max(float(v), 0.0), 1.0) | |
| x = v * (len(RAMP) - 1) | |
| i = int(x) | |
| if i >= len(RAMP) - 1: | |
| return RAMP[-1] | |
| f = x - i | |
| a = RAMP[i].lstrip("#") | |
| b = RAMP[i + 1].lstrip("#") | |
| out = [] | |
| for k in (0, 2, 4): | |
| ca = int(a[k:k + 2], 16) | |
| cb = int(b[k:k + 2], 16) | |
| out.append(int(round(ca + (cb - ca) * f))) | |
| return "#{:02x}{:02x}{:02x}".format(*out) | |
| def _select_payload(records, patches): | |
| """One PDBe Mol* QueryParam per residue: its ramp colour and its own tooltip. | |
| Addressed by AUTHOR chain / residue number / insertion code, which is the numbering | |
| in the file we hand the viewer -- so nothing has to be mapped, and the tooltip Mol* | |
| shows on hover is the same residue the score belongs to. | |
| """ | |
| out = [] | |
| for r in records: | |
| key = "{}|{}|{}".format(r["chain"], r["resid"], r["icode"]) | |
| patch = patches.get(key) | |
| if r["score"] < 0: | |
| tip = "{} {}{} - not scored".format(r["resname"], r["chain"], r["resid"]) | |
| elif patch: | |
| tip = "{} {}{} - targetability {:.3f} - 7 A patch: {} residues, rASA {}".format( | |
| r["resname"], r["chain"], r["resid"], r["score"], patch["size"], patch["rasa"]) | |
| else: | |
| tip = "{} {}{} - targetability {:.3f} - buried (rASA <= 0.20), no patch".format( | |
| r["resname"], r["chain"], r["resid"], r["score"]) | |
| q = { | |
| "auth_asym_id": r["chain"], | |
| "auth_residue_number": int(r["resid"]), | |
| "color": _ramp_color(r["score"]), | |
| "tooltip": tip, | |
| } | |
| if r["icode"]: | |
| q["auth_ins_code_id"] = r["icode"] | |
| out.append(q) | |
| return out | |
| def _viewer_document(pdb_text, records, patches, style): | |
| """Self-contained page: a PDBe Mol* view of the scored structure. | |
| Third viewer, and the reasoning is worth keeping. 3Dmol takes secondary structure | |
| from HELIX/SHEET records, which PeSTo-processed files do not have, so every residue | |
| rendered as coil. Mol*'s own CDN bundle derives secondary structure correctly but | |
| exports only `Viewer` -- no StructureElement, no Script -- so a custom hover readout | |
| or patch selection cannot be built against it. NGL exports a real API and picks | |
| cartoons correctly, but its molecular-surface picking resolves mesh vertices to the | |
| wrong atoms (it ships `disablePicking: true` by default, which now looks deliberate). | |
| PDBe Mol* is Mol*'s renderer behind a documented component API, which is the | |
| combination that actually exists. It matters here because colour and readout stop | |
| being custom code: `visual.select` takes one entry per residue carrying its own | |
| `color` AND `tooltip`, keyed by AUTHOR chain/residue/insertion code -- the numbering | |
| already in the file. So Mol* renders its own tooltip on its own correct pick, and the | |
| only interactive code left is highlighting the patch. | |
| """ | |
| sel_js = json.dumps(records, separators=(",", ":")) | |
| # key -> the line shown in the readout strip. Reuses the same text as the QueryParam | |
| # tooltip, which Mol* does not surface in this path. | |
| tips = {} | |
| for q in records: | |
| k = "{}|{}|{}".format(q["auth_asym_id"], q["auth_residue_number"], | |
| q.get("auth_ins_code_id", "")) | |
| tips[k] = q.get("tooltip", "") | |
| tips_js = json.dumps(tips, separators=(",", ":")) | |
| patches_js = json.dumps(patches, separators=(",", ":")) | |
| bar_stops = ", ".join( | |
| f"{c} {int(round(100 * i / (len(RAMP) - 1)))}%" for i, c in enumerate(RAMP) | |
| ) | |
| visual_style = "molecular-surface" if style == "surface" else "cartoon" | |
| return f"""<!doctype html> | |
| <html><head><meta charset="utf-8"> | |
| <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/pdbe-molstar@3.3.0/build/pdbe-molstar.css"> | |
| <script src="https://cdn.jsdelivr.net/npm/pdbe-molstar@3.3.0/build/pdbe-molstar-component.js"></script> | |
| <style> | |
| :root {{ | |
| color-scheme: light dark; | |
| --surface-1: #fcfcfb; --text-primary: #0b0b0b; | |
| --text-secondary: #52514e; --text-muted: #78766f; --hairline: #e3e2de; | |
| }} | |
| @media (prefers-color-scheme: dark) {{ | |
| :root {{ | |
| --surface-1: #1a1a19; --text-primary: #ffffff; | |
| --text-secondary: #c3c2b7; --text-muted: #9a9890; --hairline: #383835; | |
| }} | |
| }} | |
| * {{ box-sizing: border-box; }} | |
| body {{ | |
| margin: 0; background: var(--surface-1); color: var(--text-primary); | |
| font: 13px/1.45 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; | |
| }} | |
| /* The 3D background is deliberately not themed: the ramp runs mid-grey -> dark red, | |
| and dark red measures 1.3:1 against a dark surface, so a high-targetability patch | |
| would vanish exactly where it matters. Against this light ground it is ~11:1. */ | |
| #view {{ position: relative; width: 100%; height: 560px; background: #f4f3f0; | |
| border: 1px solid var(--hairline); border-radius: 8px; overflow: hidden; }} | |
| #view pdbe-molstar {{ display: block; width: 100%; height: 100%; }} | |
| .viewer-msg {{ padding: 18px; color: var(--text-secondary); font-size: 12px; }} | |
| .scalebar {{ display: flex; align-items: center; gap: 8px; margin: 10px 2px 2px; | |
| color: var(--text-secondary); font-size: 11px; flex-wrap: wrap; }} | |
| .scalebar .ramp {{ flex: 0 0 200px; height: 9px; border-radius: 5px; | |
| background: linear-gradient(to right, {bar_stops}); }} | |
| .scalebar .swatch {{ display: inline-block; width: 10px; height: 10px; border-radius: 3px; | |
| background: {PATCH_COLOR}; vertical-align: -1px; margin-right: 4px; }} | |
| .scalebar .swatch.centre {{ background: {CENTRE_COLOR}; }} | |
| .hint {{ color: var(--text-muted); }} | |
| /* Fixed readout rather than a floating tooltip: nothing to position, nothing to | |
| collide with Mol*'s own tooltip, and it stays put while the structure is rotated. */ | |
| #readout {{ margin: 8px 2px 0; min-height: 20px; font-size: 13px; | |
| color: var(--text-primary); font-variant-numeric: tabular-nums; }} | |
| #readout .idle {{ color: var(--text-muted); }} | |
| </style></head> | |
| <body> | |
| <div id="view"></div> | |
| <div id="readout"><span class="idle">hover a residue for its targetability and patch</span></div> | |
| <div class="scalebar"> | |
| <span>targetability 0</span><span class="ramp"></span><span>1</span> | |
| <span style="margin-left:14px"><span class="swatch"></span>7 A patch<span class="swatch centre" style="margin-left:10px"></span>the residue it is centred on</span> | |
| <span class="hint" style="margin-left:14px">hover a residue · drag to rotate · scroll to zoom</span> | |
| </div> | |
| <textarea id="pdbdata" style="display:none">{html.escape(pdb_text)}</textarea> | |
| <script> | |
| // One entry per residue: its ramp colour and its own tooltip text. Mol* renders the | |
| // tooltip itself, on its own pick, so there is no custom hover readout to get wrong. | |
| const SELECT_DATA = {sel_js}; | |
| const PATCHES = {patches_js}; | |
| const TIPS = {tips_js}; | |
| const PATCH_COLOR = "{PATCH_COLOR}"; | |
| const UNSCORED_COLOR = "{UNSCORED_COLOR}"; | |
| const VISUAL_STYLE = "{visual_style}"; | |
| const view = document.getElementById("view"); | |
| const readout = document.getElementById("readout"); | |
| const IDLE = "<span class='idle'>hover a residue for its targetability and patch</span>"; | |
| function fail(msg) {{ | |
| view.innerHTML = "<div class='viewer-msg'>3D view unavailable: " + msg + | |
| "<br><small>The downloads below are unaffected.</small></div>"; | |
| }} | |
| function keyOf(chain, resno, ins) {{ | |
| return chain + "|" + resno + "|" + (ins || "").trim(); | |
| }} | |
| // The mouseover payload is not covered by the published type definitions, so read | |
| // the plausible field names rather than assuming one. Whichever exists, wins. | |
| function keyFromEvent(d) {{ | |
| if (!d) return null; | |
| const chain = d.auth_asym_id != null ? d.auth_asym_id : d.chain_id; | |
| const resno = d.auth_seq_id != null ? d.auth_seq_id | |
| : (d.residue_number != null ? d.residue_number : d.auth_residue_number); | |
| if (chain == null || resno == null) return null; | |
| return keyOf(chain, resno, d.ins_code || d.auth_ins_code_id || ""); | |
| }} | |
| function queryFor(k) {{ | |
| const p = k.split("|"); | |
| const q = {{ auth_asym_id: p[0], auth_residue_number: parseInt(p[1], 10) }}; | |
| if (p[2]) q.auth_ins_code_id = p[2]; | |
| return q; | |
| }} | |
| if (!customElements) {{ | |
| fail("this browser does not support web components"); | |
| }} else {{ | |
| const blob = new Blob([document.getElementById("pdbdata").value], {{ type: "text/plain" }}); | |
| const url = URL.createObjectURL(blob); | |
| const el = document.createElement("pdbe-molstar"); | |
| el.setAttribute("custom-data-url", url); | |
| el.setAttribute("custom-data-format", "pdb"); // 'pdb' is a supported format, so | |
| el.setAttribute("hide-controls", "true"); // no conversion and no renumbering | |
| el.setAttribute("hide-water", "true"); | |
| el.setAttribute("sequence-panel", "false"); | |
| el.setAttribute("pdbe-link", "false"); | |
| el.setAttribute("landscape", "true"); | |
| el.setAttribute("visual-style", VISUAL_STYLE); | |
| el.setAttribute("lighting", "matte"); | |
| el.setAttribute("subscribe-events", "true"); // required for PDB.molstar.* events | |
| el.setAttribute("select-interaction", "false"); // clicking must not hijack colours | |
| el.setAttribute("bg-color-r", "244"); | |
| el.setAttribute("bg-color-g", "243"); | |
| el.setAttribute("bg-color-b", "240"); | |
| // TWO CHANNELS, and this is the whole trick. | |
| // | |
| // Mol* highlights whatever single residue the cursor is over, and that cannot be | |
| // switched off or given patch granularity. While the patch also used the highlight | |
| // channel, the two shared a colour: make the default invisible and the patch went | |
| // with it; make it blue and the single residue flashed blue first. | |
| // | |
| // So the hover highlight is painted in the BACKGROUND colour -- it still happens, | |
| // it is simply never visible -- and the patch is drawn through the independent | |
| // SELECTION channel, which carries its own colour. The score's resolution is the | |
| // patch, so the patch is the only thing that ever appears. | |
| // Mol*'s own hover highlight IS the centre residue -- the one the patch is defined | |
| // around. Earlier it was painted in the background colour to suppress it, which is | |
| // what made single residues render white. Give it a deeper blue instead and the two | |
| // channels carry two facts at once: the patch, and which residue it belongs to. | |
| el.setAttribute("highlight-color-r", "13"); | |
| el.setAttribute("highlight-color-g", "54"); | |
| el.setAttribute("highlight-color-b", "107"); | |
| el.setAttribute("select-color-r", "28"); | |
| el.setAttribute("select-color-g", "92"); | |
| el.setAttribute("select-color-b", "171"); | |
| view.appendChild(el); | |
| // the component upgrades asynchronously; viewerInstance appears once it has | |
| let tries = 0; | |
| (function waitForInstance() {{ | |
| const vi = el.viewerInstance; | |
| if (!vi) {{ | |
| if (++tries > 200) return fail("PDBe Mol* did not initialise"); | |
| return setTimeout(waitForInstance, 50); | |
| }} | |
| // Two marking channels, two facts. The HIGHLIGHT channel is Mol*'s own hover | |
| // marking and lands on the residue under the cursor -- the patch centre. The | |
| // SELECT channel carries the patch. Painting the highlight in the background | |
| // colour (the earlier attempt at hiding it) is what made residues render white; | |
| // colouring it properly is both cheaper and more informative. | |
| function tuneMarkingChannels() {{ | |
| try {{ | |
| vi.plugin.canvas3d.setProps({{ | |
| // Centre stronger than the patch, and its outline restored, so the residue | |
| // the score belongs to is legible inside its own neighbourhood. | |
| renderer: {{ highlightStrength: 0.85, selectStrength: 0.5 }}, | |
| marking: {{ highlightEdgeStrength: 1 }}, | |
| }}); | |
| return true; | |
| }} catch (e) {{ | |
| console.warn("could not tune marking channels:", e); | |
| return false; | |
| }} | |
| }} | |
| try {{ | |
| vi.events.loadComplete.subscribe(() => {{ | |
| // Paint every residue by targetability, and give each its own tooltip. Anything | |
| // not in the list (waters, unselected chains) takes nonSelectedColor. | |
| vi.visual.select({{ data: SELECT_DATA, nonSelectedColor: UNSCORED_COLOR }}) | |
| .catch(e => console.warn("select failed:", e)); | |
| // canvas3d exists only once the structure is rendered | |
| tuneMarkingChannels(); | |
| URL.revokeObjectURL(url); | |
| }}); | |
| }} catch (e) {{ console.warn("loadComplete unavailable:", e); }} | |
| // The only interactive piece left: mark the patch the hovered value refers to. | |
| // | |
| // Not via visual.highlight(): that is async (a promise per hover, ~0.2 s) and it | |
| // rewrites the global highlight colour. getLociForParams() builds the loci | |
| // synchronously, so the patch can be handed straight to Mol*'s own highlight | |
| // manager inside the same event tick that highlighted the single residue -- | |
| // there is no intermediate frame where only one residue is lit. | |
| const lociCache = new Map(); | |
| function patchLoci(key, patch) {{ | |
| let l = lociCache.get(key); | |
| if (l === undefined) {{ | |
| try {{ l = vi.getLociForParams(patch.members.map(queryFor)); }} | |
| catch (e) {{ console.warn("loci build failed:", e); l = null; }} | |
| lociCache.set(key, l); | |
| }} | |
| return l; | |
| }} | |
| // Draw the patch on the selection channel: its own colour, independent of the | |
| // hover highlight, and synchronous so it lands in the same event tick. | |
| function showPatch(loci) {{ | |
| try {{ | |
| const sel = vi.plugin.managers.interactivity.lociSelects; | |
| if (loci) sel.selectOnly({{ loci: loci }}); | |
| else sel.deselectAll(); | |
| return true; | |
| }} catch (e) {{ console.warn("selection channel unavailable:", e); return false; }} | |
| }} | |
| let lastKey = null; | |
| document.addEventListener("PDB.molstar.mouseover", ev => {{ | |
| const key = keyFromEvent(ev.eventData); | |
| if (key === lastKey) return; | |
| lastKey = key; | |
| readout.innerHTML = (key && TIPS[key]) ? TIPS[key] : IDLE; | |
| const patch = key && PATCHES[key]; | |
| if (!patch) {{ showPatch(null); return; }} | |
| const loci = patchLoci(key, patch); | |
| // fall back to the async API only if the synchronous path is unavailable | |
| if (!showPatch(loci)) {{ | |
| vi.visual.highlight({{ data: patch.members.map(queryFor) }}) | |
| .catch(e => console.warn("highlight failed:", e)); | |
| }} | |
| }}); | |
| document.addEventListener("PDB.molstar.mouseout", () => {{ | |
| lastKey = null; | |
| readout.innerHTML = IDLE; | |
| if (!showPatch(null)) vi.visual.clearHighlight().catch(() => {{}}); | |
| }}); | |
| }})(); | |
| }} | |
| </script> | |
| </body></html>""" | |
| def _iframe(document, height=760): | |
| return ( | |
| f"<iframe style='width:100%;height:{height}px;border:0;' " | |
| f"sandbox='allow-scripts allow-same-origin' " | |
| f"srcdoc=\"{html.escape(document, quote=True)}\"></iframe>" | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # main callback | |
| # --------------------------------------------------------------------------- | |
| def score(pdb_file, pdb_id, chains, style, progress=gr.Progress()): | |
| workdir = tempfile.mkdtemp(prefix="bindscout_") | |
| progress(0.05, desc="reading structure") | |
| pdb_path, stem = _resolve_input(pdb_file, pdb_id, workdir) | |
| chain_list = [c for c in re.split(r"[,\s]+", chains or "") if c] | |
| progress(0.15, desc="running the GNN (this is CPU-bound; large targets take minutes)") | |
| out_pdb = os.path.join(workdir, f"{stem}_targetability.pdb") | |
| try: | |
| pred = _run_model(pdb_path, chain_list, out_pdb) | |
| except ValueError as e: | |
| raise gr.Error(str(e)) | |
| except Exception as e: | |
| traceback.print_exc() | |
| raise gr.Error(f"prediction failed: {type(e).__name__}: {e}") | |
| progress(0.9, desc="rendering") | |
| out_csv = pred.to_csv(os.path.join(workdir, f"{stem}_targetability.csv")) | |
| prot = pred.protein_records() | |
| if not prot: | |
| # the graph accepts nucleic acids and ligands as context, but the head was | |
| # trained to score amino acids -- there is nothing to report without them | |
| raise gr.Error("no amino-acid residues in the selection -- this model scores protein targets") | |
| s = pred.scores() | |
| # No mean of the scores. Each per-residue value is already an aggregate over that | |
| # residue's 7 A patch, so averaging them again produces a number that means nothing | |
| # -- and it would be dominated by the buried majority, which are 0 by construction. | |
| # The peak and the counts above a threshold are the quantities that read honestly. | |
| summary = ( | |
| f"**{stem}** · {len(prot)} protein residues scored " | |
| f" · chains {' '.join(pred.chains)} · {pred.num_atoms} atoms \n" | |
| f"peak **{s.max():.3f}** · " | |
| f"{int((s >= 0.5).sum())} residues ≥ 0.50 · " | |
| f"{int((s >= 0.2).sum())} residues ≥ 0.20" | |
| ) | |
| # the 7 A patch each value refers to, on the scored file so the keys match what the | |
| # viewer reads back. A failure here costs the hover detail, not the prediction. | |
| try: | |
| patch_index = compute_patches(out_pdb) | |
| except Exception as e: | |
| traceback.print_exc() | |
| print(f"[bindscout] patch index unavailable: {type(e).__name__}: {e}") | |
| patch_index = {} | |
| select_data = _select_payload(prot, patch_index) | |
| viewer = _iframe(_viewer_document(pred.pdb_text, select_data, patch_index, style)) | |
| return viewer, summary, out_pdb, out_csv | |
| # --------------------------------------------------------------------------- | |
| # MCP tool | |
| # --------------------------------------------------------------------------- | |
| def score_target(structure: str, chains: str = "", top_n: int = 15) -> str: | |
| """BindScout: score a protein target for BindCraft targetability, per residue. | |
| Returns the residues BindCraft designs are most likely to bury, ranked. The score is | |
| local surface-patch coverage: how frequently independent successful BindCraft | |
| trajectories would completely cover that residue's 7 A patch. Read it as a ranking of | |
| candidate sites within this target, not as an absolute probability of design success. | |
| BindScout is specific to the BindCraft design and filtering procedure in its training | |
| data, which is mainly single, structurally coherent protein domains. | |
| Args: | |
| structure: a 4-character RCSB PDB id (e.g. "1M47"), or the full contents of a | |
| PDB file as text. | |
| chains: optional comma-separated author chain ids to score, e.g. "A,B". Empty | |
| scores every chain in the file. The model sees every chain supplied, so a | |
| bound partner left in changes the result -- pass the target chain alone to | |
| reproduce how BindCraft sees it. | |
| top_n: how many top-ranked residues to return. | |
| Returns: | |
| A text report: the peak score, how many residues clear 0.5 and 0.2, and the | |
| top-ranked residues with their score, 7 A patch size and relative solvent | |
| accessibility. | |
| """ | |
| workdir = tempfile.mkdtemp(prefix="bindscout_mcp_") | |
| text = (structure or "").strip() | |
| if not text: | |
| return "Error: give a 4-character PDB id or the contents of a PDB file." | |
| if PDB_ID_RE.match(text): | |
| pdb_path, stem = _resolve_input(None, text, workdir) | |
| elif "ATOM" in text: | |
| stem = "uploaded" | |
| pdb_path = os.path.join(workdir, "uploaded.pdb") | |
| with open(pdb_path, "w") as fo: | |
| fo.write(text) | |
| else: | |
| return ("Error: `structure` must be a 4-character PDB id or PDB file contents " | |
| "(no ATOM records found).") | |
| chain_list = [c for c in re.split(r"[,\s]+", chains or "") if c] | |
| out_pdb = os.path.join(workdir, f"{stem}_targetability.pdb") | |
| try: | |
| pred = _run_model(pdb_path, chain_list, out_pdb) | |
| except Exception as e: | |
| return f"Error: prediction failed ({type(e).__name__}: {e})" | |
| prot = pred.protein_records() | |
| if not prot: | |
| return "Error: no amino-acid residues in the selection; this model scores proteins." | |
| try: | |
| patch_index = compute_patches(out_pdb) | |
| except Exception: | |
| patch_index = {} | |
| s = pred.scores() | |
| lines = [ | |
| f"{stem}: {len(prot)} protein residues scored, chains {' '.join(pred.chains)}.", | |
| f"Peak targetability {s.max():.3f}. " | |
| f"{int((s >= 0.5).sum())} residues >= 0.50, {int((s >= 0.2).sum())} >= 0.20.", | |
| "", | |
| "rank residue targetability patch rASA", | |
| ] | |
| for i, r in enumerate(pred.top_residues(top_n), 1): | |
| key = "{}|{}|{}".format(r["chain"], r["resid"], r["icode"]) | |
| p = patch_index.get(key) | |
| lines.append("{:>4} {:<8} {:>10.3f} {:>5} {:>4}".format( | |
| i, "{} {}{}".format(r["resname"], r["chain"], r["resid"]), | |
| r["score"], | |
| p["size"] if p else "-", | |
| "{:.2f}".format(p["rasa"]) if p else "-")) | |
| lines += [ | |
| "", | |
| "Read these as a ranking of candidate sites within this target, not as absolute " | |
| "probabilities of design success. Score the same construct and chains BindCraft " | |
| "will actually see.", | |
| ] | |
| return "\n".join(lines) | |
| INTRO = """ | |
| # BindScout | |
| BindScout is a geometric neural network based on [PeSTo](https://github.com/LBM-EPFL/PeSTo) | |
| that predicts how readily different regions of a protein surface are targeted by BindCraft. | |
| Upload a structure and BindScout returns the same structure with a targetability score for | |
| each residue written to the B-factor field, ready for visualization in PyMOL or ChimeraX | |
| (`spectrum b, grey80_red, minimum=0, maximum=1`). | |
| The score reflects local surface-patch coverage, not simply residue contact frequency. For | |
| each exposed residue, a local patch is defined by all exposed residues within 7 Å (Cβ–Cβ; | |
| Cα for glycine). BindScout estimates how frequently independent successful BindCraft | |
| trajectories completely cover this patch. A residue therefore scores only when its entire | |
| local neighbourhood is contacted, concentrating the signal on the interiors of targeted | |
| regions rather than their edges. | |
| Interpret BindScout scores primarily as a ranking of candidate sites within a target. They | |
| indicate where successful BindCraft designs are preferentially obtained, rather than the | |
| absolute probability of design success or an intrinsic property of the protein surface. | |
| BindScout is specific to the BindCraft design and filtering procedure represented in its | |
| training data. | |
| Keep the training distribution in mind. BindScout was trained on the Human Bindome, a | |
| proteome-scale atlas of BindCraft designs covering 8 296 human proteins at domain level | |
| (Wenckstern et al., bioRxiv 2026), so mainly single, structurally coherent protein domains. | |
| Very short fragments or large multichain assemblies are further from its training data. Its | |
| local geometric architecture should allow reasonable generalization, but unusual constructs | |
| should be interpreted with more caution. Whenever possible, score the same construct and chains that BindCraft will | |
| actually see. | |
| If you make use of BindScout in your work, please cite this Space: [huggingface.co/spaces/arvban/bindscout](https://huggingface.co/spaces/arvban/bindscout), and the Human Bindome preprint for the training data. | |
| """ | |
| # System fonts rather than the theme's default Google font: one less third-party | |
| # fetch the viewer's browser has to complete before the page renders, which matters | |
| # on a restricted network and shaves the cold load everywhere else. | |
| THEME = gr.themes.Soft( | |
| font=["system-ui", "-apple-system", "Segoe UI", "Roboto", "sans-serif"], | |
| font_mono=["ui-monospace", "SFMono-Regular", "Menlo", "monospace"], | |
| ) | |
| with gr.Blocks(title="BindScout", theme=THEME) as demo: | |
| gr.Markdown(INTRO) | |
| with gr.Row(): | |
| with gr.Column(scale=1): | |
| f_in = gr.File(label="Structure file (.pdb / .cif)", file_types=[".pdb", ".ent", ".cif", ".mmcif"]) | |
| id_in = gr.Textbox(label="…or an RCSB PDB id", placeholder="1M47", max_lines=1) | |
| ch_in = gr.Textbox( | |
| label="Chains to score", | |
| placeholder="all chains", | |
| info="comma-separated, e.g. A,B — everything else is removed before the graph is built", | |
| max_lines=1, | |
| ) | |
| st_in = gr.Radio( | |
| ["cartoon", "surface"], value="cartoon", label="3D representation" | |
| ) | |
| go = gr.Button("Score structure", variant="primary") | |
| gr.Markdown( | |
| f"<small>CPU inference: about 30 seconds per 200 residues, scaling with " | |
| f"atom count. Limit {MAX_ATOMS:,} atoms (~2500 residues) — for anything " | |
| f"larger, or for batches, clone the Space and run <code>inference.py</code> " | |
| f"locally.</small>" | |
| ) | |
| with gr.Column(scale=2): | |
| out_summary = gr.Markdown() | |
| out_view = gr.HTML() | |
| with gr.Row(): | |
| out_pdb = gr.File(label="Scored PDB (targetability in B-factors)") | |
| out_csv = gr.File(label="Per-residue CSV") | |
| go.click( | |
| score, | |
| inputs=[f_in, id_in, ch_in, st_in], | |
| outputs=[out_view, out_summary, out_pdb, out_csv], | |
| ) | |
| # No UI: this exists to be called. With mcp_server=True it becomes an MCP tool, | |
| # its schema taken from the type hints and its description from the docstring. | |
| gr.api(score_target, api_name="score_target") | |
| examples_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "examples") | |
| example_files = sorted( | |
| os.path.join(examples_dir, f) for f in os.listdir(examples_dir) | |
| ) if os.path.isdir(examples_dir) else [] | |
| if example_files: | |
| gr.Examples( | |
| examples=[[f, "", "", "surface"] for f in example_files], | |
| inputs=[f_in, id_in, ch_in, st_in], | |
| label="Examples", | |
| ) | |
| if __name__ == "__main__": | |
| # ssr_mode=False: gradio 5 turns on server-side rendering whenever Node is present, | |
| # which it is in the Spaces image but not in a plain conda env -- and there the Node | |
| # process dies on startup and takes the app down with it ("Stopping Node.js server"). | |
| # Nothing here needs SSR; the page is a normal client-rendered gradio app. | |
| # mcp_server=True publishes the API endpoints as MCP tools at /gradio_api/mcp/sse. | |
| # HF already installs gradio[oauth,mcp], so this needs no extra dependency. | |
| demo.queue(max_size=12).launch(ssr_mode=False, mcp_server=True) | |