""" Uric-Acid Colorimetric Concentration Predictor — interactive demo. Smartphone RGB reading of an oxidised-TMB well (and, ideally, the same phone's blank reading) -> predicted uric-acid concentration from four models. The ANN released with the manuscript is highlighted. Models, all evaluated on the same 28-sample held-out fold (manuscript §3.8): Linear (raw RGB) per-buffer OLS on R,G,B overall R² 0.587, RMSE 128.5 μM Linear (ΔRGB) per-buffer OLS on ΔR,ΔG,ΔB overall R² 0.722, RMSE 105.4 μM Random Forest 500 trees, depth 10, all feats overall R² 0.893, RMSE 65.3 μM ANN (this work) 5-seed MLP-Wide-Reg [128,64] overall R² 0.874, RMSE 70.9 μM This file changes ONLY presentation. The model loading and prediction math are unchanged from the working version. """ import json, pickle import numpy as np import torch import torch.nn as nn import gradio as gr BUFFER_ORDER = ["DI", "pH4", "pH11", "SBF"] BUFFER_LABELS = { "DI": "DI water", "pH4": "pH 4 buffer", "pH11": "pH 11 buffer", "SBF": "Simulated body fluid (SBF)", } CONC_MAX = 600.0 # μM, training range ceiling — used to scale the result bars # Published held-out metrics (manuscript §3.8, Table 2 / Figure 3) PUBLISHED = { "Linear (raw RGB)": {"R2": 0.587, "RMSE": 128.5}, "Linear (ΔRGB)": {"R2": 0.722, "RMSE": 105.4}, "Random Forest": {"R2": 0.893, "RMSE": 65.3}, "ANN (this work)": {"R2": 0.874, "RMSE": 70.9}, } MODEL_META = { "Linear (raw RGB)": {"sub": "per-buffer OLS · R,G,B", "accent": "#94a3b8"}, "Linear (ΔRGB)": {"sub": "per-buffer OLS · ΔR,ΔG,ΔB", "accent": "#64748b"}, "Random Forest": {"sub": "500 trees · depth 10 · all feats", "accent": "#f59e0b"}, "ANN (this work)": {"sub": "5-seed MLP-Wide-Reg [128, 64]", "accent": "#4f46e5"}, } # ---------------------------------------------------------------- load artifacts class MLP(nn.Module): def __init__(self, in_dim, hidden, dropout=0.10): super().__init__() layers, prev = [], in_dim for h in hidden: layers += [nn.Linear(prev, h), nn.ReLU(), nn.Dropout(dropout)] prev = h layers.append(nn.Linear(prev, 1)) self.net = nn.Sequential(*layers) def forward(self, x): return self.net(x).squeeze(-1) _ck = torch.load("model.pt", map_location="cpu", weights_only=False) _ann = [] for sd in _ck["ensemble_state_dicts"]: m = MLP(_ck["in_dim"], _ck["architecture"]); m.load_state_dict(sd); m.eval() _ann.append(m) _scaler_mean = np.asarray(_ck["scaler_mean"], np.float32) _scaler_scale = np.asarray(_ck["scaler_scale"], np.float32) with open("baselines_linear.json") as f: _lin = json.load(f) with open("rf_model.pkl", "rb") as f: _rf_bundle = pickle.load(f); _rf = _rf_bundle["model"] with open("mean_blanks.json") as f: _mean_blanks = json.load(f) # ---------------------------------------------------------------- feature builders def _ohe(buffer): v = [0.0] * 4; v[BUFFER_ORDER.index(buffer)] = 1.0; return v def _all_features(R, G, B, dR, dG, dB, buffer): return np.asarray([R, G, B, dR, dG, dB] + _ohe(buffer), np.float32) def _lin_predict(block, cols_vals, buffer): p = _lin[block][buffer] return float(np.dot(p["coef"], cols_vals) + p["intercept"]) # ---------------------------------------------------------------- prediction core def _compute(buffer, R, G, B, use_blank, R0, G0, B0): """Return (preds_dict, dR, dG, dB, blank_note). Identical math to the working app.""" if use_blank: r0, g0, b0 = R0, G0, B0 blank_note = "user-supplied per-device blank" else: mb = _mean_blanks[buffer] r0, g0, b0 = mb["R"], mb["G"], mb["B"] blank_note = "cohort-mean blank (no per-device blank given)" dR, dG, dB = r0 - R, g0 - G, b0 - B p_lin_rgb = _lin_predict("lin_rgb", [R, G, B], buffer) p_lin_drgb = _lin_predict("lin_drgb", [dR, dG, dB], buffer) x_all = _all_features(R, G, B, dR, dG, dB, buffer).reshape(1, -1) p_rf = float(_rf.predict(x_all)[0]) x_std = (x_all.ravel() - _scaler_mean) / _scaler_scale xt = torch.tensor(x_std, dtype=torch.float32).unsqueeze(0) with torch.no_grad(): p_ann = float(np.mean([m(xt).item() for m in _ann])) clamp = lambda v: max(0.0, v) preds = { "Linear (raw RGB)": clamp(p_lin_rgb), "Linear (ΔRGB)": clamp(p_lin_drgb), "Random Forest": clamp(p_rf), "ANN (this work)": clamp(p_ann), } return preds, dR, dG, dB, blank_note # ---------------------------------------------------------------- HTML rendering def _hero(preds, buffer, blank_note, dR, dG, dB): ann = preds["ANN (this work)"] others = [v for k, v in preds.items() if k != "ANN (this work)"] consensus = np.mean(list(preds.values())) pct = max(2.0, min(100.0, 100.0 * ann / CONC_MAX)) return f"""
ANN predicted concentration
{ann:.1f}μM
released model · 5-seed MLP-Wide-Reg [128, 64]
Buffer{BUFFER_LABELS[buffer]}
4-model mean{consensus:.1f} μM
ΔR / ΔG / ΔB{dR:.1f} / {dG:.1f} / {dB:.1f}
0150300450600 μM
ΔRGB source: {blank_note}
""" def _model_card(name, value): meta = MODEL_META[name]; pub = PUBLISHED[name] is_ann = name == "ANN (this work)" pct = max(2.0, min(100.0, 100.0 * value / CONC_MAX)) cls = "mcard ann" if is_ann else "mcard" star = 'RELEASED' if is_ann else "" return f"""
{name} {star}
{value:.1f}μM
{meta['sub']}
R² {pub['R2']:.3f} RMSE {pub['RMSE']:.1f} μM
""" def _detail(preds): order = ["ANN (this work)", "Random Forest", "Linear (ΔRGB)", "Linear (raw RGB)"] cards = "".join(_model_card(n, preds[n]) for n in order) return f"""
{cards}
Bars show each model's predicted concentration on a 0–600 μM scale. R² and RMSE are the published held-out values (n = 28, manuscript §3.8), not per-sample uncertainties. Predictions are clamped at 0 μM and are most reliable within 0–600 μM.
""" def predict(buffer, R, G, B, use_blank, R0, G0, B0): preds, dR, dG, dB, note = _compute(buffer, R, G, B, use_blank, R0, G0, B0) return _hero(preds, buffer, note, dR, dG, dB), _detail(preds) # ---------------------------------------------------------------- styling CUSTOM_CSS = """ :root{ --indigo:#4f46e5; --indigo-soft:#eef2ff; --ink:#0f172a; --muted:#64748b; --line:#e2e8f0; --bg:#f8fafc; --card:#ffffff; } .gradio-container{max-width:1180px!important;margin:0 auto!important;background:var(--bg);} #hdr{padding:18px 4px 6px;border-bottom:1px solid var(--line);margin-bottom:14px;} #hdr h1{font-size:1.55rem;line-height:1.2;margin:0;color:var(--ink);font-weight:700;} #hdr .accent{display:inline-block;width:42px;height:4px;border-radius:3px; background:linear-gradient(90deg,var(--indigo),#22c55e);margin:8px 0 6px;} #hdr p{color:var(--muted);margin:2px 0 0;font-size:.93rem;} .panel{background:var(--card);border:1px solid var(--line);border-radius:16px; padding:16px 18px;box-shadow:0 1px 2px rgba(15,23,42,.04);} .panel h3{margin:.1rem 0 .7rem;font-size:1rem;color:var(--ink);font-weight:650;} .sectlabel{font-size:.72rem;letter-spacing:.06em;text-transform:uppercase; color:var(--muted);font-weight:700;margin:10px 0 4px;} button.primary,.gr-button-primary{border-radius:12px!important;font-weight:650!important; background:var(--indigo)!important;border:none!important;} /* hero */ .hero{background:linear-gradient(135deg,#eef2ff 0%,#f5f3ff 60%,#ecfdf5 140%); border:1px solid #e0e7ff;border-radius:18px;padding:18px 20px;} .hero-row{display:flex;justify-content:space-between;gap:16px;flex-wrap:wrap;align-items:flex-start;} .hero-label{font-size:.74rem;letter-spacing:.06em;text-transform:uppercase;color:var(--indigo);font-weight:700;} .hero-value{font-size:3rem;line-height:1.05;font-weight:800;color:var(--ink); font-variant-numeric:tabular-nums;margin-top:2px;} .hero-unit{font-size:1.2rem;font-weight:600;color:var(--muted);margin-left:6px;} .hero-meta{color:var(--muted);font-size:.85rem;margin-top:2px;} .hero-side{display:flex;flex-direction:column;gap:6px;min-width:210px;} .chip{display:flex;justify-content:space-between;gap:10px;background:rgba(255,255,255,.7); border:1px solid #e0e7ff;border-radius:10px;padding:6px 10px;font-size:.82rem;color:var(--muted);} .chip b{color:var(--ink);font-variant-numeric:tabular-nums;} .scale{margin-top:16px;} .scale-track{position:relative;height:8px;background:#e5e7eb;border-radius:6px;} .scale-fill{position:absolute;left:0;top:0;height:100%;border-radius:6px; background:linear-gradient(90deg,var(--indigo),#22c55e);} .scale-knob{position:absolute;top:50%;width:14px;height:14px;border-radius:50%; background:#fff;border:3px solid var(--indigo);transform:translate(-50%,-50%);} .scale-ticks{display:flex;justify-content:space-between;color:var(--muted); font-size:.72rem;margin-top:5px;font-variant-numeric:tabular-nums;} .blank-note{margin-top:10px;color:var(--muted);font-size:.8rem;} /* model cards grid */ .grid{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:14px;} .mcard{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:12px 14px;} .mcard.ann{border:1.5px solid var(--indigo);background:linear-gradient(180deg,#fbfbff,#f5f3ff); box-shadow:0 4px 14px rgba(79,70,229,.12);} .mc-top{display:flex;justify-content:space-between;align-items:baseline;gap:8px;} .mc-name{font-weight:650;color:var(--ink);font-size:.92rem;} .badge-ann{font-size:.6rem;letter-spacing:.05em;background:var(--indigo);color:#fff; padding:2px 6px;border-radius:6px;vertical-align:middle;margin-left:4px;} .mc-val{font-size:1.35rem;font-weight:750;color:var(--ink);font-variant-numeric:tabular-nums;} .mc-unit{font-size:.78rem;color:var(--muted);font-weight:600;margin-left:3px;} .mc-sub{color:var(--muted);font-size:.78rem;margin:2px 0 8px;} .bar{height:7px;background:#eef2f7;border-radius:5px;overflow:hidden;} .bar-fill{height:100%;border-radius:5px;} .mc-stats{display:flex;gap:14px;margin-top:8px;} .stat{font-size:.76rem;color:var(--muted);} .stat b{color:var(--ink);} .legend{color:var(--muted);font-size:.76rem;margin-top:12px;line-height:1.5;} @media (max-width:760px){.grid{grid-template-columns:1fr;}} """ HEADER = """

🧪 Uric-Acid Colorimetric Concentration Predictor

Smartphone RGB → uric-acid concentration · Ag–Cu micro-flower / TMB nanozyme assay · four models compared, the manuscript ANN highlighted.

""" INTRO = ( "Enter the measured **R, G, B** of the reaction well and choose the **buffer**. " "If you have the same phone's **blank (0 μM)** reading, switch on *Use per-device " "blank* for best accuracy; otherwise a cohort-mean blank is used." ) # ---------------------------------------------------------------- UI with gr.Blocks( title="Uric-Acid Colorimetric Concentration Predictor", theme=gr.themes.Soft(primary_hue="indigo", secondary_hue="emerald", neutral_hue="slate"), css=CUSTOM_CSS, ) as demo: gr.HTML(HEADER) with gr.Row(equal_height=False): # ---- Input column ---- with gr.Column(scale=4, min_width=320): with gr.Group(): gr.Markdown(INTRO) gr.HTML('
Assay matrix
') buffer = gr.Dropdown(BUFFER_ORDER, value="DI", label="Buffer", info="Matrix the assay was run in") gr.HTML('
Measurement RGB · oxidised-TMB well
') with gr.Row(): R = gr.Number(value=50.0, label="R", precision=2) G = gr.Number(value=70.0, label="G", precision=2) B = gr.Number(value=90.0, label="B", precision=2) use_blank = gr.Checkbox(value=False, label="Use per-device blank (recommended)") gr.HTML('
Blank RGB · same phone, 0 μM well
') with gr.Row(): R0 = gr.Number(value=60.0, label="R₀", precision=2) G0 = gr.Number(value=78.0, label="G₀", precision=2) B0 = gr.Number(value=104.0, label="B₀", precision=2) btn = gr.Button("Predict concentration", variant="primary", size="lg") # ---- Results column ---- with gr.Column(scale=6, min_width=420): hero = gr.HTML() detail = gr.HTML() with gr.Accordion("Worked examples (real held-out measurements) — click to load, then Predict", open=False): gr.Examples( examples=[ ["pH4", 7.04, 68.67, 99.89, True, 49.66, 74.12, 105.82], ["DI", 44.0, 56.0, 75.0, True, 60.0, 78.0, 104.0], ["pH11", 96.93, 125.47, 145.70, True, 96.93, 125.47, 145.70], ["SBF", 60.0, 100.0, 120.0, False, 0, 0, 0], ], inputs=[buffer, R, G, B, use_blank, R0, G0, B0], ) gr.Markdown( "**Model performance (held-out fold, n = 28, manuscript §3.8):** " "Linear raw-RGB R² 0.587 · Linear ΔRGB R² 0.722 · Random Forest R² 0.893 · " "**ANN R² 0.874, RMSE 70.9 μM**. The ANN is the most even performer across all " "four buffers and is the released inference model. _Research use only; not a medical device._" ) inputs = [buffer, R, G, B, use_blank, R0, G0, B0] btn.click(predict, inputs, [hero, detail]) # show a result on load so the page is never empty demo.load(predict, inputs, [hero, detail]) if __name__ == "__main__": demo.launch()