pockethb-demo / app.py
bubbaonbubba's picture
preload backbone at startup + timing/multi-photo hint in description
cd69ef3 verified
Raw
History Blame Contribute Delete
7.15 kB
"""HuggingFace Space — pocketHb interactive demo.
Lets a visitor upload a few iPhone-style fingernail photos and (optionally) provide
their real bloodwork Hb. The Space loads the pocketHb global model + fits an
affine per-user calibrator on the spot, returning both raw and personalised
estimates with a small chart.
NOT a medical device. Disclaimers travel with the weights.
"""
from __future__ import annotations
import io
from pathlib import Path
import gradio as gr
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from PIL import Image
from pockethb.inference import InferenceSession
_SESS: InferenceSession | None = None
def get_session() -> InferenceSession:
global _SESS
if _SESS is None:
_SESS = InferenceSession.from_hub(repo_id="bubbaonbubba/pockethb-base")
# Warm the frozen backbone so the first user-facing request doesn't
# eat the ~30-60s cold-start cost on cpu-basic.
_SESS._get_backbone()
return _SESS
# preload at module-import time so HF Spaces boot absorbs the cold start,
# not the first user request
try:
get_session()
print("[startup] InferenceSession warm — backbone loaded.")
except Exception as e:
print(f"[startup] warm-up failed (will retry on first request): {type(e).__name__}: {e}")
def _make_chart(raw_per: np.ndarray, personal_per: np.ndarray | None, true_hb: float | None) -> Image.Image:
fig, ax = plt.subplots(figsize=(9, 4.5))
idx = np.arange(len(raw_per))
ax.scatter(idx, raw_per, color="red", s=60, alpha=0.7, label=f"global raw mean={raw_per.mean():.2f}")
if personal_per is not None:
ax.scatter(idx, personal_per, color="green", s=60, alpha=0.7,
label=f"personalised mean={personal_per.mean():.2f}")
for i in idx:
ax.plot([i, i], [raw_per[i], personal_per[i]], color="grey", lw=0.5, alpha=0.5)
if true_hb is not None:
ax.axhline(true_hb, color="black", ls="--", lw=1.2, label=f"your truth = {true_hb}")
ax.set_xticks(idx)
ax.set_xticklabels([f"#{i+1}" for i in idx], rotation=0, fontsize=9)
ax.set_xlabel("photo")
ax.set_ylabel("Hb estimate (g/dL)")
ax.set_title("pocketHb — global vs personalised per photo")
ax.legend(loc="best", fontsize=9)
ax.grid(alpha=0.3)
plt.tight_layout()
buf = io.BytesIO()
plt.savefig(buf, format="png", dpi=110)
plt.close(fig)
buf.seek(0)
return Image.open(buf)
def _to_pil(file_obj) -> Image.Image:
if isinstance(file_obj, str):
return Image.open(file_obj).convert("RGB")
if isinstance(file_obj, Image.Image):
return file_obj.convert("RGB")
if hasattr(file_obj, "name"):
return Image.open(file_obj.name).convert("RGB")
raise ValueError(f"can't convert {type(file_obj)} to PIL.Image")
def predict(files, hb_anchor):
if not files:
return "**no photos uploaded.** drag nail photos into the box above.", None, None
try:
sess = get_session()
photos = [_to_pil(f) for f in files]
raw_per = sess.predict_per_image(photos)
if hb_anchor is None or hb_anchor == 0 or hb_anchor == "":
chart = _make_chart(raw_per, None, None)
md = (
f"### global model (no personalisation)\n\n"
f"- mean estimate across {len(photos)} photos: **{raw_per.mean():.2f} g/dL**\n"
f"- per-photo spread (std): {raw_per.std():.2f} g/dL\n\n"
f"_to see the personalisation layer in action, enter your real bloodwork Hb on the right and rerun._"
)
df = pd.DataFrame({"photo": [f"#{i+1}" for i in range(len(photos))],
"global raw (g/dL)": raw_per.round(2)})
return md, df, chart
hb = float(hb_anchor)
cal = sess.calibrate(photos, hb)
personal_per = cal.predict(raw_per)
chart = _make_chart(raw_per, personal_per, hb)
raw_mae = float(np.mean(np.abs(raw_per - hb)))
personal_mae = float(np.mean(np.abs(personal_per - hb)))
md = (
f"### personalised against your Hb = **{hb:.2f} g/dL**\n\n"
f"| | mean across photos | per-photo MAE vs your truth |\n"
f"|---|---|---|\n"
f"| global raw | **{raw_per.mean():.2f} g/dL** | {raw_mae:.2f} |\n"
f"| personalised | **{personal_per.mean():.2f} g/dL** | **{personal_mae:.2f}** |\n\n"
f"calibrator: `mode={cal.mode}, a={cal.a:.3f}, b={cal.b:+.3f}, anchors={cal.n_anchors_used}`\n\n"
f"the personalisation step removes the global model's systematic bias for you. "
f"residual per-photo MAE is the irreducible photo-to-photo noise (lighting, focus, "
f"crop angle) — averaging more photos at inference time reduces it."
)
df = pd.DataFrame({
"photo": [f"#{i+1}" for i in range(len(photos))],
"global raw (g/dL)": raw_per.round(2),
"personalised (g/dL)": personal_per.round(2),
"err vs truth": (personal_per - hb).round(2),
})
return md, df, chart
except Exception as e:
return f"**error:** `{type(e).__name__}: {e}`", None, None
DESCRIPTION = """
# pocketHb — interactive demo
open-source replication of the **personalisation layer** from the Mannino et al. PNAS 2025
fingernail-Hb paper. drop in a few nail photos (taken with a consistent protocol — same finger,
varied lighting, white paper as reference). optionally provide your real bloodwork Hb value to
see the per-user calibrator fit on the spot.
[github](https://github.com/jayanthvee/pocketHb) ·
[base weights on HF Hub](https://huggingface.co/bubbaonbubba/pockethb-base) ·
[capture protocol](https://github.com/jayanthvee/pocketHb/blob/main/docs/capture_protocol.md)
**not a medical device. research replication only. do not use to estimate anyone's actual hemoglobin in any clinical, diagnostic, or treatment context. not FDA cleared. get a blood test.**
_runs on a free cpu-basic Space; expect ~5 s per photo. uploading 3+ photos at once is recommended — the model was trained on 3-crop bags per patient and single-photo inference is off-distribution._
"""
with gr.Blocks(title="pocketHb demo") as demo:
gr.Markdown(DESCRIPTION)
with gr.Row():
with gr.Column(scale=2):
files = gr.Files(
label="upload 3–15 fingernail photos (jpg/png)",
file_count="multiple",
file_types=["image"],
)
with gr.Column(scale=1):
hb_anchor = gr.Number(
label="your real Hb in g/dL (optional, enables personalisation)",
value=15.3,
precision=2,
)
run_btn = gr.Button("run", variant="primary")
summary = gr.Markdown()
table = gr.Dataframe(label="per-photo predictions")
chart = gr.Image(label="visualisation", type="pil")
run_btn.click(predict, inputs=[files, hb_anchor], outputs=[summary, table, chart])
if __name__ == "__main__":
demo.launch()