bindscout / app.py
arvban's picture
BindScout: BindCraft patch-coverage targetability
d847871 verified
Raw History Blame Contribute Delete
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
@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"""<!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 &middot; drag to rotate &middot; 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}** &nbsp;·&nbsp; {len(prot)} protein residues scored "
f"&nbsp;·&nbsp; chains {' '.join(pred.chains)} &nbsp;·&nbsp; {pred.num_atoms} atoms \n"
f"peak **{s.max():.3f}** &nbsp;·&nbsp; "
f"{int((s >= 0.5).sum())} residues ≥ 0.50 &nbsp;·&nbsp; "
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)