from __future__ import annotations
import html
import os
import urllib.error
import urllib.request
from functools import lru_cache
from pathlib import Path
import gradio as gr
import numpy as np
from huggingface_hub import snapshot_download
from PIL import Image, ImageDraw
from rdkit import Chem
from rdkit.Chem import AllChem, Descriptors, Draw, Lipinski
from affinity.inference import ThreeOnnxAffinityPredictor
AMINO_ACIDS = set("ACDEFGHIKLMNPQRSTVWY")
RESIDUE_COLORS = {
"D": "#ef4444",
"E": "#ef4444",
"K": "#3b82f6",
"R": "#3b82f6",
"H": "#3b82f6",
"A": "#22c55e",
"V": "#22c55e",
"I": "#22c55e",
"L": "#22c55e",
"M": "#22c55e",
"F": "#22c55e",
"W": "#22c55e",
"Y": "#22c55e",
}
ATOM_COLORS = {
"C": "#334155",
"H": "#cbd5e1",
"N": "#2563eb",
"O": "#dc2626",
"F": "#16a34a",
"P": "#ea580c",
"S": "#ca8a04",
"CL": "#16a34a",
"BR": "#92400e",
"I": "#7e22ce",
}
PAIR_EXAMPLES = [
{
"name": "Ubiquitin + caffeine",
"protein": (
"MQIFVKTLTGKTITLEVEPSDTIENVKAKIQDKEGIPPDQQRLIFAGKQLEDGRTL"
"SDYNIQKESTLHLVLRLRGG"
),
"smiles": "CN1C=NC2=C1C(=O)N(C(=O)N2C)C",
},
{
"name": "Lysozyme + ibuprofen",
"protein": (
"KVFGRCELAAAMKRHGLDNYRGYSLGNWVCAAKFESNFNTQATNRNTDGSTDYGIL"
"QINSRWWCNDGRTPGSRNLCNIPCSALLSSDITASVNCAKKIVSDGNGMNAWVAWR"
"NRCKGTDVQAWIRGCRL"
),
"smiles": "CC(C)CC1=CC=C(C=C1)C(C)C(=O)O",
},
{
"name": "Insulin B chain + aspirin",
"protein": "FVNQHLCGSHLVEALYLVCGERGFFYTPKT",
"smiles": "CC(=O)OC1=CC=CC=C1C(=O)O",
},
{
"name": "Villin headpiece + acetaminophen",
"protein": "LSDEDFKAVFGMTRSAFANLPLWKQQNLKKEKGLF",
"smiles": "CC(=O)NC1=CC=C(C=C1)O",
},
]
PDB_EXAMPLES = [
("1UBQ", "Ubiquitin"),
("1LYZ", "Lysozyme"),
("1CRN", "Crambin"),
("1L2Y", "Trp-cage"),
]
def validate_protein(sequence: str) -> str:
sequence = "".join(sequence.upper().split())
if not sequence or set(sequence) - AMINO_ACIDS:
raise gr.Error("Protein must contain only the 20 standard amino-acid letters.")
return sequence
def validate_smiles(smiles: str) -> str:
smiles = smiles.strip()
if Chem.MolFromSmiles(smiles) is None:
raise gr.Error("The SMILES string is invalid.")
return smiles
def _resolve_model(
local_variables: str | tuple[str, ...],
repo_variables: str | tuple[str, ...],
allow_patterns: list[str] | None,
) -> Path:
local_variables = (
(local_variables,) if isinstance(local_variables, str) else local_variables
)
repo_variables = (repo_variables,) if isinstance(repo_variables, str) else repo_variables
local_path = next((os.getenv(name, "") for name in local_variables if os.getenv(name)), "")
if local_path:
path = Path(local_path).expanduser().resolve()
if not path.exists():
raise RuntimeError(f"Local model path does not exist: {path}")
return path
repo_id = next((os.getenv(name, "") for name in repo_variables if os.getenv(name)), "")
if not repo_id:
raise RuntimeError(
f"Set one of {local_variables} or one of {repo_variables}"
)
return Path(
snapshot_download(
repo_id,
token=os.getenv("HF_TOKEN"),
allow_patterns=allow_patterns,
)
)
@lru_cache(maxsize=1)
def get_predictor() -> ThreeOnnxAffinityPredictor:
return ThreeOnnxAffinityPredictor(
artifact_directory=_resolve_model(
"AFFINITY_MODEL_PATH",
"AFFINITY_MODEL_REPO",
["model.onnx", "normalization.npz", "metadata.json"],
),
protein_directory=_resolve_model(
("PROTEIN_ONNX_PATH", "PROLLAMA_ONNX_PATH"),
("PROTEIN_ONNX_REPO", "PROLLAMA_ONNX_REPO"),
None,
),
molecule_directory=_resolve_model(
("MOLECULE_ONNX_PATH", "MOL_LLAMA_ONNX_PATH"),
("MOLECULE_ONNX_REPO", "MOL_LLAMA_ONNX_REPO"),
None,
),
device=os.getenv("ONNX_DEVICE", "auto"),
)
def predict(sequence: str, smiles: str):
sequence = validate_protein(sequence)
smiles = validate_smiles(smiles)
try:
prediction = float(get_predictor().predict([sequence], [smiles])[0])
except Exception as error:
raise gr.Error(f"Inference failed: {error}") from error
try:
conformer = molecule_3d(smiles)
except Exception as error:
gr.Warning(f"Could not generate the 3D conformer: {error}")
conformer = None
return (
prediction_card(prediction),
molecule_2d(smiles),
conformer,
molecule_summary(smiles),
protein_render(sequence),
protein_summary(sequence),
)
def pair_example(index: int) -> tuple[str, str]:
example = PAIR_EXAMPLES[index]
return example["protein"], example["smiles"]
def prediction_card(prediction: float) -> str:
return (
"
"
"
Predicted affinity score
"
f"
{prediction:.4f}
"
"
Model output on the dataset target scale
"
"
"
)
def molecule_summary(smiles: str) -> dict:
molecule = Chem.MolFromSmiles(validate_smiles(smiles))
return {
"molecular_weight": round(Descriptors.MolWt(molecule), 3),
"log_p": round(Descriptors.MolLogP(molecule), 3),
"h_bond_donors": Lipinski.NumHDonors(molecule),
"h_bond_acceptors": Lipinski.NumHAcceptors(molecule),
"rotatable_bonds": Lipinski.NumRotatableBonds(molecule),
"heavy_atoms": molecule.GetNumHeavyAtoms(),
}
def protein_summary(sequence: str) -> dict:
sequence = validate_protein(sequence)
return {
"length": len(sequence),
"acidic_fraction": round(sum(sequence.count(aa) for aa in "DE") / len(sequence), 4),
"basic_fraction": round(sum(sequence.count(aa) for aa in "KRH") / len(sequence), 4),
"hydrophobic_fraction": round(
sum(sequence.count(aa) for aa in "AVILMFWY") / len(sequence), 4
),
}
def protein_render(sequence: str) -> str:
sequence = validate_protein(sequence)
residues = "".join(
"{html.escape(residue)}"
for index, residue in enumerate(sequence, start=1)
)
return (
""
f""
f"
{residues}
"
"
"
"Acidic"
"Basic"
"Hydrophobic"
"Other"
"
"
)
def molecule_2d(smiles: str):
return Draw.MolToImage(Chem.MolFromSmiles(validate_smiles(smiles)), size=(700, 450))
def _project_coordinates(
coordinates: np.ndarray,
width: int,
height: int,
padding: int = 50,
) -> tuple[np.ndarray, np.ndarray]:
angle_y = np.deg2rad(-28)
angle_x = np.deg2rad(18)
rotate_y = np.array(
[
[np.cos(angle_y), 0, np.sin(angle_y)],
[0, 1, 0],
[-np.sin(angle_y), 0, np.cos(angle_y)],
],
dtype=np.float32,
)
rotate_x = np.array(
[
[1, 0, 0],
[0, np.cos(angle_x), -np.sin(angle_x)],
[0, np.sin(angle_x), np.cos(angle_x)],
],
dtype=np.float32,
)
rotated = (coordinates - coordinates.mean(axis=0)) @ rotate_y.T @ rotate_x.T
xy = rotated[:, :2]
span = np.maximum(np.ptp(xy, axis=0), 1e-6)
scale = min((width - 2 * padding) / span[0], (height - 2 * padding) / span[1])
projected = xy * scale
projected[:, 0] += width / 2
projected[:, 1] = height / 2 - projected[:, 1]
return projected, rotated[:, 2]
def molecule_3d(smiles: str) -> Image.Image:
molecule = Chem.AddHs(Chem.MolFromSmiles(validate_smiles(smiles)))
parameters = AllChem.ETKDGv3()
parameters.randomSeed = 42
if AllChem.EmbedMolecule(molecule, parameters) != 0:
raise gr.Error("RDKit could not generate a conformer for this molecule.")
if AllChem.MMFFHasAllMoleculeParams(molecule):
AllChem.MMFFOptimizeMolecule(molecule, maxIters=500)
else:
AllChem.UFFOptimizeMolecule(molecule, maxIters=500)
conformer = molecule.GetConformer()
coordinates = np.array(
[
[
conformer.GetAtomPosition(index).x,
conformer.GetAtomPosition(index).y,
conformer.GetAtomPosition(index).z,
]
for index in range(molecule.GetNumAtoms())
],
dtype=np.float32,
)
width, height = 760, 460
points, depth = _project_coordinates(coordinates, width, height)
image = Image.new("RGB", (width, height), "#f8fafc")
drawing = ImageDraw.Draw(image)
for bond in molecule.GetBonds():
start = tuple(map(float, points[bond.GetBeginAtomIdx()]))
end = tuple(map(float, points[bond.GetEndAtomIdx()]))
drawing.line([start, end], fill="#64748b", width=4)
depth_range = max(float(np.ptp(depth)), 1e-6)
for index in np.argsort(depth):
atom = molecule.GetAtomWithIdx(int(index))
x, y = points[index]
relative_depth = (float(depth[index]) - float(depth.min())) / depth_range
radius = int(7 + 5 * relative_depth)
color = ATOM_COLORS.get(atom.GetSymbol().upper(), "#64748b")
drawing.ellipse(
(x - radius, y - radius, x + radius, y + radius),
fill=color,
outline="#ffffff",
width=2,
)
if atom.GetSymbol() != "H":
drawing.text((x + radius + 2, y - radius), atom.GetSymbol(), fill="#0f172a")
return image
def protein_backbone_image(pdb_text: str) -> Image.Image:
coordinates = []
for line in pdb_text.splitlines():
if line.startswith(("ATOM ", "HETATM")) and line[12:16].strip() == "CA":
try:
coordinates.append(
[float(line[30:38]), float(line[38:46]), float(line[46:54])]
)
except ValueError:
continue
if len(coordinates) < 2:
raise gr.Error("The PDB file does not contain enough alpha-carbon coordinates.")
width, height = 900, 600
points, _ = _project_coordinates(
np.asarray(coordinates, dtype=np.float32), width, height, padding=60
)
image = Image.new("RGB", (width, height), "#f8fafc")
drawing = ImageDraw.Draw(image)
denominator = max(len(points) - 1, 1)
for index in range(len(points) - 1):
fraction = index / denominator
color = (
int(37 + 202 * fraction),
int(99 + 20 * (1 - fraction)),
int(235 - 160 * fraction),
)
drawing.line(
[
tuple(map(float, points[index])),
tuple(map(float, points[index + 1])),
],
fill=color,
width=5,
)
return image
def protein_3d(pdb_file) -> Image.Image:
if pdb_file is None:
raise gr.Error("Upload a PDB file. A sequence alone has no 3D coordinates.")
pdb_text = Path(pdb_file).read_text(encoding="utf-8", errors="replace")
return protein_backbone_image(pdb_text)
@lru_cache(maxsize=len(PDB_EXAMPLES))
def load_sample_pdb(pdb_id: str) -> Image.Image:
valid_ids = {identifier for identifier, _ in PDB_EXAMPLES}
if pdb_id not in valid_ids:
raise gr.Error("Unknown sample PDB identifier.")
request = urllib.request.Request(
f"https://files.rcsb.org/download/{pdb_id}.pdb",
headers={"User-Agent": "protein-compound-affinity-space/1.0"},
)
try:
with urllib.request.urlopen(request, timeout=30) as response:
pdb_text = response.read().decode("utf-8", errors="replace")
except (urllib.error.URLError, TimeoutError) as error:
raise gr.Error(f"Could not download {pdb_id} from RCSB PDB: {error}") from error
return protein_backbone_image(pdb_text)
EXAMPLE_PROTEIN = "MAVMKNYLLPILVLFLAYYYYSTNEEFRPEMLQGKKVIVTGASKGIGREMAYHLSKMGAHVVLTARSEEGLQK"
EXAMPLE_SMILES = "C1CC1(C2=CC=C(C=C2)F)C(=O)N3CC4CC4(C3)C5=CNC6=C5C=CC=N6"
CSS = """
.gradio-container {max-width: 1220px !important; margin: auto !important;}
.hero {text-align:center; padding:1.2rem 0 .4rem;}
.hero h1 {margin-bottom:.35rem;}
.hero p {color:#64748b; margin:0 auto; max-width:760px;}
.input-card, .result-card {
border:1px solid #e2e8f0; border-radius:16px; padding:18px;
background:var(--block-background-fill);
}
.score-card {
color:white; text-align:center; border-radius:16px; padding:24px;
background:linear-gradient(135deg,#4338ca,#2563eb);
box-shadow:0 12px 28px rgba(37,99,235,.2);
}
.score-label {font-size:15px; opacity:.9;}
.score-value {font-size:48px; line-height:1.15; font-weight:750; margin:6px 0;}
.score-note {font-size:12px; opacity:.75;}
.protein-card {border:1px solid #e2e8f0; border-radius:12px; padding:14px;}
.sequence-header {font-weight:650; margin-bottom:10px;}
.sequence-map {display:flex; flex-wrap:wrap; gap:3px; max-height:280px; overflow:auto;}
.residue {
color:white; width:24px; height:24px; line-height:24px; text-align:center;
border-radius:4px; font:600 12px ui-monospace,monospace;
}
.sequence-legend {display:flex; flex-wrap:wrap; gap:14px; margin-top:12px; font-size:12px;}
.sequence-legend span {display:flex; align-items:center; gap:5px;}
.sequence-legend i {display:inline-block; width:10px; height:10px; border-radius:3px;}
.render-note {padding:24px; text-align:center; color:#64748b;}
"""
with gr.Blocks(title="Protein-Compound Affinity Explorer", css=CSS) as demo:
gr.Markdown(
"Protein-Compound Affinity Explorer
"
"
Paste a protein sequence and compound SMILES to predict their affinity "
"and inspect both inputs. Inference runs entirely through ONNX.
"
)
with gr.Group(elem_classes="input-card"):
protein = gr.Textbox(
label="Protein sequence",
placeholder="Paste the amino-acid sequence here...",
lines=7,
value=EXAMPLE_PROTEIN,
)
smiles = gr.Textbox(
label="Compound SMILES",
placeholder="Paste the compound SMILES here...",
value=EXAMPLE_SMILES,
)
run = gr.Button("Predict affinity and render inputs", variant="primary", size="lg")
gr.Markdown("#### One-click examples")
with gr.Row():
example_buttons = [
gr.Button(example["name"], size="sm") for example in PAIR_EXAMPLES
]
with gr.Column(visible=False) as results:
score = gr.HTML()
gr.Markdown("## Compound")
with gr.Row():
image = gr.Image(label="2D structure", height=420)
view_3d = gr.Image(label="Generated 3D conformer projection", height=420)
molecule_data = gr.JSON(label="Molecule descriptors")
gr.Markdown("## Protein")
protein_view = gr.HTML()
protein_data = gr.JSON(label="Protein descriptors")
run.click(
predict,
[protein, smiles],
[score, image, view_3d, molecule_data, protein_view, protein_data],
).then(lambda: gr.update(visible=True), outputs=results)
for index, button in enumerate(example_buttons):
button.click(
lambda selected=index: pair_example(selected),
outputs=[protein, smiles],
).then(
predict,
[protein, smiles],
[score, image, view_3d, molecule_data, protein_view, protein_data],
).then(lambda: gr.update(visible=True), outputs=results)
with gr.Accordion("Optional protein 3D viewer", open=False):
gr.Markdown(
"A protein sequence does not contain 3D coordinates. Upload a PDB file "
"or select one of the four RCSB PDB examples."
)
pdb = gr.File(label="PDB file", file_types=[".pdb"], type="filepath")
pdb_view = gr.Image(label="Protein backbone projection", height=520)
gr.Button("Render PDB structure").click(protein_3d, pdb, pdb_view)
gr.Markdown("#### Sample PDB structures")
with gr.Row():
pdb_buttons = [
gr.Button(f"{pdb_id} ยท {name}", size="sm")
for pdb_id, name in PDB_EXAMPLES
]
for (pdb_id, _), button in zip(PDB_EXAMPLES, pdb_buttons):
button.click(
lambda selected=pdb_id: load_sample_pdb(selected),
outputs=pdb_view,
)
gr.Markdown(
html.escape(
"Research demonstration only. Predictions are not medical or drug-development advice."
)
)
if __name__ == "__main__":
demo.queue(default_concurrency_limit=1, max_size=8).launch(
server_name="0.0.0.0",
server_port=7860,
)