"""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 @gpu_task 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"""
hover a residue for its targetability and patch
targetability 01 7 A patchthe residue it is centred on hover a residue · drag to rotate · scroll to zoom
""" def _iframe(document, height=760): return ( f"" ) # --------------------------------------------------------------------------- # 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"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 inference.py " f"locally." ) 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)