| """ |
| 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 |
|
|
| |
| 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"}, |
| } |
|
|
| |
| 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) |
|
|
| |
| 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"]) |
|
|
| |
| 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 |
|
|
| |
| 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""" |
| <div class="hero"> |
| <div class="hero-row"> |
| <div class="hero-main"> |
| <div class="hero-label">ANN predicted concentration</div> |
| <div class="hero-value">{ann:.1f}<span class="hero-unit">μM</span></div> |
| <div class="hero-meta">released model · 5-seed MLP-Wide-Reg [128, 64]</div> |
| </div> |
| <div class="hero-side"> |
| <div class="chip"><span>Buffer</span><b>{BUFFER_LABELS[buffer]}</b></div> |
| <div class="chip"><span>4-model mean</span><b>{consensus:.1f} μM</b></div> |
| <div class="chip"><span>ΔR / ΔG / ΔB</span><b>{dR:.1f} / {dG:.1f} / {dB:.1f}</b></div> |
| </div> |
| </div> |
| <div class="scale"> |
| <div class="scale-track"><div class="scale-fill" style="width:{pct:.1f}%"></div> |
| <div class="scale-knob" style="left:{pct:.1f}%"></div></div> |
| <div class="scale-ticks"><span>0</span><span>150</span><span>300</span><span>450</span><span>600 μM</span></div> |
| </div> |
| <div class="blank-note">ΔRGB source: <i>{blank_note}</i></div> |
| </div> |
| """ |
|
|
| 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 = '<span class="badge-ann">RELEASED</span>' if is_ann else "" |
| return f""" |
| <div class="{cls}"> |
| <div class="mc-top"> |
| <div class="mc-name">{name} {star}</div> |
| <div class="mc-val">{value:.1f}<span class="mc-unit">μM</span></div> |
| </div> |
| <div class="mc-sub">{meta['sub']}</div> |
| <div class="bar"><div class="bar-fill" style="width:{pct:.1f}%;background:{meta['accent']}"></div></div> |
| <div class="mc-stats"> |
| <span class="stat">R² <b>{pub['R2']:.3f}</b></span> |
| <span class="stat">RMSE <b>{pub['RMSE']:.1f} μM</b></span> |
| </div> |
| </div> |
| """ |
|
|
| 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""" |
| <div class="grid">{cards}</div> |
| <div class="legend">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.</div> |
| """ |
|
|
| 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) |
|
|
| |
| 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 = """ |
| <div id="hdr"> |
| <h1>🧪 Uric-Acid Colorimetric Concentration Predictor</h1> |
| <div class="accent"></div> |
| <p>Smartphone RGB → uric-acid concentration · Ag–Cu micro-flower / TMB nanozyme assay · |
| four models compared, the manuscript ANN highlighted.</p> |
| </div> |
| """ |
|
|
| 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." |
| ) |
|
|
| |
| 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): |
| |
| with gr.Column(scale=4, min_width=320): |
| with gr.Group(): |
| gr.Markdown(INTRO) |
| gr.HTML('<div class="sectlabel">Assay matrix</div>') |
| buffer = gr.Dropdown(BUFFER_ORDER, value="DI", label="Buffer", |
| info="Matrix the assay was run in") |
| gr.HTML('<div class="sectlabel">Measurement RGB · oxidised-TMB well</div>') |
| 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('<div class="sectlabel">Blank RGB · same phone, 0 μM well</div>') |
| 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") |
|
|
| |
| 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]) |
| |
| demo.load(predict, inputs, [hero, detail]) |
|
|
| if __name__ == "__main__": |
| demo.launch() |
|
|