"""LeukoAI 1.1 web application (research use only). Run locally: python app/app.py then open http://127.0.0.1:7860 Hugging Face: see docs/DEPLOY.md """ from __future__ import annotations import csv import glob import html import os import sys import tempfile import gradio as gr import torch ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, ROOT) from leukoai import load_models # noqa: E402 from leukoai.explain import gradcam, overlay # noqa: E402 from leukoai.models import DX1, DX2, DX3 # noqa: E402 from leukoai.pipeline import analyse_paths # noqa: E402 torch.set_num_threads(max(1, os.cpu_count() or 1)) MODELS = load_models(device="cpu") MAX_IMAGES = int(os.environ.get("LEUKOAI_MAX_IMAGES", "100")) USERS = dict(u.strip().split(":", 1) for u in os.environ.get("APP_USERS", "").split(",") if ":" in u) def check_login(username, password): ok = USERS.get(username) == password print(f"[ACCESS] user={username} login={'ok' if ok else 'FAILED'}", flush=True) return ok ASSETS = os.path.join(ROOT, "assets") PAPER = "https://doi.org/10.21037/jmai-24-74" CODE = "https://github.com/synav/LeukoAI" DATA = "https://doi.org/10.5281/zenodo.17814776" CARD = "https://github.com/synav/LeukoAI/blob/main/MODEL_CARD.md" EXAMPLES = { key: sorted(glob.glob(os.path.join(ASSETS, "examples", key, "*.jpg"))) for key in ("aml", "cll", "cml", "normal") } def _logo_svg() -> str: path = os.path.join(ASSETS, "logo-mark.svg") if not os.path.exists(path): return "" with open(path) as f: return f.read().replace(" """ HEADER = f"""
{LOGO_SVG}LeukoAI
""" HERO = """

LeukoAI 1.1 · Research tool

Leukemia prediction from peripheral blood smear images

Upload the microscope fields from one patient's smear. LeukoAI classifies every field and combines them into one case result: acute or chronic leukemia, the likely subtype (ALL, AML, CLL, CML) or no leukemia, with heatmaps showing where the model looked.

417/417images give identical results to the published desktop tool
0/417real smear fields falsely flagged by the image check
No scannerworks on ordinary photos taken at the microscope
""" STEPS = """
1

Capture

Photograph 20 or more fields of one smear at 1000× (oil immersion, May-Grünwald-Giemsa stain).

2

Upload

Select all the images at once (Ctrl+A in the folder) and tick the de-identification box.

3

Review

Read the case result, the warnings and the heatmaps on the most influential fields.

""" PLACEHOLDER = """

Your result will appear here

Upload one patient's smear fields, or load an example case, then select Analyse case. Analysis takes about 1–2 seconds per image.

""" ABOUT = f"""

About LeukoAI

How it works

Three DenseNet-121 models work in sequence: the first screens each field for leukemia, the second separates acute from chronic, and the third suggests the subtype. A rule then combines the fields into one case result: leukemia is reported when more than 3 fields are classed leukemic.

How it was validated

The published study used 7,255 images from 162 adult patients. On 64 external patients the acute / chronic / no-leukemia result reached an F1 score of 0.94 (accuracy 0.92); the five-class subtype result reached a macro-F1 of 0.77. This web version reproduces the original desktop tool exactly.

Limitations

  • Single centre, adult patients, 1000× MGG-stained peripheral smears only.
  • ALL vs AML separation is weak (F1 0.36 for ALL); treat the subtype as unconfirmed.
  • APML is not reported separately. Scores are not calibrated probabilities.

Privacy

Images are used only for your analysis and are deleted from the server automatically within about an hour. Nothing is kept for training. Upload de-identified images only, with no names or numbers burned in.

Cite

Naveed S, et al. Novel hierarchical deep learning models predict type of leukemia from whole slide microscopic images of peripheral blood. Journal of Medical Artificial Intelligence. 2025. doi:10.21037/jmai-24-74

Model card: github.com/synav/LeukoAI · Public image dataset: Zenodo 10.5281/zenodo.17814776

""" FOOTER = """ """ CSS = """ :root, .gradio-container { --lk-ink:#14212E; --lk-muted:#55606E; --lk-line:#DCE3EA; --lk-ground:#F4F6F9; --lk-brand:#2B6698; --lk-brand-dark:#1D4A70; --lk-card:#FFFFFF; } html, body, gradio-app, .gradio-container { background: var(--lk-ground) !important; } .gradio-container { max-width: min(1180px, 100vw) !important; margin: 0 auto !important; padding: 0 clamp(10px, 2.5vw, 16px) !important; overflow-x: hidden; font-family: 'IBM Plex Sans', system-ui, sans-serif !important; color: var(--lk-ink); } .lk-nav { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:12px; padding: 18px 0 14px; border-bottom: 1px solid var(--lk-line); } .lk-brand { display:inline-flex; align-items:center; gap:10px; text-decoration:none; } .lk-mark { width: clamp(34px, 4.5vw, 42px); height: clamp(34px, 4.5vw, 42px); display:inline-block; } .lk-mark svg { width:100%; height:100%; display:block; } .lk-wordmark { font-weight:700; font-size: clamp(22px, 2.8vw, 27px); letter-spacing:-0.01em; color:var(--lk-ink); } .lk-wordmark span { color: var(--lk-brand); } .lk-links { display:flex; flex-wrap:wrap; gap: 4px 22px; } .lk-links a { color: var(--lk-ink); text-decoration:none; font-weight:500; font-size:15px; padding:8px 0; } .lk-links a:hover { color: var(--lk-brand); } .lk-hero { padding: clamp(28px, 5vw, 44px) 0 clamp(20px, 3vw, 28px); } .lk-eyebrow { font-family:'IBM Plex Mono', monospace; font-size:13px; letter-spacing:.08em; text-transform:uppercase; color: var(--lk-brand); font-weight:600; margin:0 0 12px; } .lk-hero h1 { font-size: clamp(30px, 4.4vw, 48px); line-height:1.1; letter-spacing:-.02em; margin:0 0 16px; font-weight:700; color:var(--lk-ink); max-width: 860px; } .lk-lead { font-size: clamp(16px, 2vw, 18px); line-height:1.55; color:var(--lk-muted); max-width: 760px; margin:0 0 28px; } .lk-stats { display:grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap:16px; border-top: 2px solid var(--lk-ink); padding-top: 20px; } .lk-stats div { display:flex; flex-direction:column; gap:6px; } .lk-stats span { font-size:15px; line-height:1.45; color:var(--lk-muted); } .lk-stats .lk-num { font-family:'IBM Plex Mono', monospace; font-size:28px; font-weight:600; color:var(--lk-brand); } .lk-steps { display:grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap:16px; margin: 8px 0 28px; } .lk-steps > div { background: var(--lk-card); border:1px solid var(--lk-line); border-radius:10px; padding:18px 20px; } .lk-steps h3 { margin: 10px 0 6px; font-size:17px; color:var(--lk-ink); } .lk-steps p { margin:0; color:var(--lk-muted); font-size:15px; line-height:1.5; } .lk-step { display:inline-flex; align-items:center; justify-content:center; width:30px; height:30px; border-radius:50%; background: var(--lk-brand); color:#fff; font-weight:600; font-size:15px; } #tool { gap: 20px; scroll-margin-top: 16px; } .lk-panel { background: var(--lk-card) !important; border:1px solid var(--lk-line) !important; border-radius:12px !important; padding: clamp(12px, 2vw, 18px) !important; max-width: 100% !important; } .gradio-container .main, .gradio-container .app { max-width: 100% !important; min-width: 0 !important; } #tool { flex-wrap: wrap !important; max-width: 100% !important; } #tool > .lk-panel:first-child { flex: 5 1 340px !important; min-width: min(340px, 100%) !important; } #tool > .lk-panel:last-child { flex: 7 1 340px !important; min-width: min(340px, 100%) !important; } .lk-panel-title { margin:0 0 4px; font-size:18px; font-weight:600; color:var(--lk-ink); } .lk-panel-sub { margin:0 0 10px; font-size:14px; color:var(--lk-muted); line-height:1.5; } .lk-ex-label { font-size:14px; color:var(--lk-muted); margin: 6px 0 0; } .lk-empty { border: 2px dashed var(--lk-line); border-radius:10px; padding: 36px 24px; text-align:center; color:var(--lk-muted); } .lk-empty-title { font-size:18px; font-weight:600; color:var(--lk-ink); margin:0 0 6px; } .lk-result { border-radius:10px; padding:18px 20px; margin-bottom:12px; border:2px solid; background:#fff; } .lk-result .lk-label { font-family:'IBM Plex Mono', monospace; font-size:12px; letter-spacing:.06em; text-transform:uppercase; color:var(--lk-muted); } .lk-result .lk-dx { font-size:30px; font-weight:700; margin:4px 0; letter-spacing:-.01em; } .lk-result .lk-sub { font-size:18px; color:var(--lk-ink); } .lk-result .lk-meta { font-size:14px; color:var(--lk-muted); margin-top:10px; line-height:1.5; } .lk-flag { padding:10px 14px; margin:8px 0; border-radius:8px; font-size:14px; line-height:1.5; color:#101828; } .lk-about { padding: 40px 0 8px; scroll-margin-top: 16px; } .lk-about h2 { font-size: 30px; margin:0 0 20px; color:var(--lk-ink); letter-spacing:-.01em; } .lk-grid { display:grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 20px 32px; } .lk-about h3 { font-size:17px; margin: 0 0 8px; color:var(--lk-ink); } .lk-about p, .lk-about li { font-size:15px; line-height:1.6; color:var(--lk-muted); margin:0 0 8px; } .lk-about ul { padding-left: 18px; margin:0; } .lk-about a, .lk-footer a { color: var(--lk-brand); } .lk-cite { margin-top: 20px; padding: 18px 20px; background: var(--lk-card); border:1px solid var(--lk-line); border-radius:10px; } .lk-small { font-size:14px !important; } .lk-footer { border-top:1px solid var(--lk-line); margin-top: 32px; padding: 20px 0 8px; } .lk-footer p { font-size:14px; color:var(--lk-muted); margin: 0 0 8px; line-height:1.5; } """ THEME = gr.themes.Base( primary_hue=gr.themes.Color(c50="#EEF4FA", c100="#D6E5F2", c200="#AECBE5", c300="#7FAAD3", c400="#4F88BE", c500="#2B6698", c600="#245A87", c700="#1D4A70", c800="#173B59", c900="#112C43", c950="#0B1E2E"), neutral_hue="slate", font=[gr.themes.GoogleFont("IBM Plex Sans"), "system-ui", "sans-serif"], font_mono=[gr.themes.GoogleFont("IBM Plex Mono"), "monospace"], radius_size="md", ).set(button_primary_background_fill="#2B6698", button_primary_background_fill_hover="#1D4A70", button_primary_text_color="#FFFFFF") # ---------------------------------------------------------------- analysis SEV_STYLE = { "warning": ("#B42318", "#FEF3F2", "Warning"), "caution": ("#93370D", "#FFFAEB", "Caution"), "info": ("#175CD3", "#EFF8FF", "Note"), } DX_COLOUR = {"NO LEUKEMIA": "#027A48", "ACUTE LEUKEMIA": "#B42318", "CHRONIC LEUKEMIA": "#93370D"} def _result_html(rep) -> str: r = rep.result colour = DX_COLOUR[r.three_class] title = r.three_class.capitalize().replace("leukemia", "leukemia") subtype = "" if r.three_class == "NO LEUKEMIA" else ( f"
Likely subtype: {html.escape(rep.subtype_display)}
") votes = " · ".join(f"{k.title() if k == 'NO LEUKEMIA' else k} {v}" for k, v in r.votes.items()) parts = [ f"
", "
Case result
", f"
{html.escape(title)}
{subtype}", f"
{r.n_images} fields analysed · {r.n_leukemic} classed leukemic " f"(needs more than {r.threshold}) · acute {r.n_acute}, chronic {r.n_chronic}
Subtype votes: {votes}
", "
", ] for fl in sorted(rep.flags, key=lambda f: ["warning", "caution", "info"].index(f.severity)): fg, bg, label = SEV_STYLE[fl.severity] parts.append(f"
" f"{label}: {html.escape(fl.message)}
") return "\n".join(parts) def _per_image_rows(rep): rows = [] for im in rep.images: q = im.quality or {} rows.append([im.name, DX1[im.k1].capitalize(), round(im.p1[1], 3), DX2[im.k2].capitalize(), DX3[im.k3], round(max(im.p3), 3), "OK" if q.get("ok", True) else "; ".join(q.get("reasons", []))]) return rows def _write_csv(rep) -> str: fd, path = tempfile.mkstemp(prefix="leukoai_", suffix=".csv") with os.fdopen(fd, "w", newline="") as f: w = csv.writer(f) w.writerow(["image"] + [f"m1_{c}" for c in DX1] + [f"m2_{c}" for c in DX2] + [f"m3_{c}" for c in DX3] + ["quality_ok", "quality_reasons"]) for im in rep.images: q = im.quality or {} w.writerow([im.name] + [round(x, 5) for x in im.p1 + im.p2 + im.p3] + [q.get("ok", ""), "; ".join(q.get("reasons", []))]) r = rep.result w.writerow([]) w.writerow(["case_result", r.three_class, "subtype", rep.subtype_display, "threshold", r.threshold]) for fl in rep.flags: w.writerow(["flag", fl.code, fl.severity, fl.message]) return path def analyse(files, threshold, show_heatmaps, consent): if not consent: raise gr.Error("Please confirm the images are de-identified and for research use.") if not files: raise gr.Error("Please upload the field images from one patient's smear, or load an example case.") paths = [f if isinstance(f, str) else f.name for f in files] if len(paths) > MAX_IMAGES: raise gr.Error(f"Please upload at most {MAX_IMAGES} images per case.") rep = analyse_paths(paths, MODELS, threshold=int(threshold), mode="1.1") by_name = {os.path.basename(p): p for p in paths} gallery = [] if rep.result.images_of_interest: chronic = rep.result.three_class == "CHRONIC LEUKEMIA" # Model 2 (acute/chronic) heatmaps localise leukocytes well; Model 1 is usually # saturated near 100% on leukemic fields, which makes its gradients uninformative. model, cls = MODELS["m2"], (1 if chronic else 0) from PIL import Image for name in rep.result.images_of_interest: img = Image.open(by_name[name]).convert("RGB") if show_heatmaps: img = overlay(img, gradcam(model, img, cls)) gallery.append((img, name)) headers = ["Image", "Screening", "Leukemia score", "Category", "Subtype vote", "Subtype score", "Image check"] return (_result_html(rep), gr.update(value=gallery, visible=bool(gallery)), gr.update(visible=True), gr.update(value=_per_image_rows(rep), headers=headers), gr.update(value=_write_csv(rep), visible=True)) _HIDDEN = (PLACEHOLDER, gr.update(value=[], visible=False), gr.update(visible=False), None, gr.update(value=None, visible=False)) def load_example(key): return (EXAMPLES[key], True) + _HIDDEN def clear(): return (None, False) + _HIDDEN # ---------------------------------------------------------------- layout with gr.Blocks(title="LeukoAI · Leukemia prediction from blood smears", delete_cache=(1800, 3600), analytics_enabled=False) as demo: gr.HTML(HEADER) gr.HTML(HERO) gr.HTML(STEPS) with gr.Row(elem_id="tool", equal_height=False): with gr.Column(scale=5, min_width=320, elem_classes="lk-panel"): gr.HTML("

Upload one patient's smear

" "

All fields from a single smear, 1000× MGG. JPEG, PNG or TIFF; up to " f"{MAX_IMAGES} images.

") files = gr.File(label="Field images", file_count="multiple", file_types=["image", ".tif", ".tiff"], height=220) gr.HTML("

No images to hand? Load a public example case " "(from the LeukoAI Zenodo dataset):

") with gr.Row(): ex_buttons = {k: gr.Button(label, size="sm", variant="secondary", min_width=70) for k, label in (("aml", "AML"), ("cll", "CLL"), ("cml", "CML"), ("normal", "Normal"))} with gr.Accordion("Settings", open=False): threshold = gr.Slider(0, 10, value=3, step=1, label="Image count threshold", info="Leukemia is reported when more than this many fields are classed " "leukemic. Published setting: 3.") show_heatmaps = gr.Checkbox(value=True, label="Show heatmaps on the most influential fields") consent = gr.Checkbox(label="I confirm these images are de-identified and used for research only") with gr.Row(): run = gr.Button("Analyse case", variant="primary", scale=3) reset = gr.Button("Clear", variant="secondary", scale=1) with gr.Column(scale=7, min_width=320, elem_classes="lk-panel"): result = gr.HTML(PLACEHOLDER) gallery = gr.Gallery(label="Most influential fields (warm colours = where the model looked)", columns=3, rows=1, height=260, object_fit="cover", visible=False) with gr.Accordion("Per-field results", open=False, visible=False) as table_box: table = gr.Dataframe(interactive=False, wrap=True) download = gr.File(label="Download all results (CSV)", visible=False) gr.HTML(ABOUT) gr.HTML(FOOTER) outputs = [result, gallery, table_box, table, download] run.click(analyse, [files, threshold, show_heatmaps, consent], outputs) for k, b in ex_buttons.items(): b.click(lambda k=k: load_example(k), None, [files, consent] + outputs) reset.click(clear, None, [files, consent] + outputs) if __name__ == "__main__": demo.queue(max_size=20).launch( server_name=os.environ.get("HOST") or os.environ.get("GRADIO_SERVER_NAME") or "127.0.0.1", server_port=int(os.environ.get("PORT") or os.environ.get("GRADIO_SERVER_PORT") or 7860), theme=THEME, css=CSS, head=HEAD, allowed_paths=[ASSETS], favicon_path=FAVICON if os.path.exists(FAVICON) else None, auth=check_login, auth_message=( "LeukoAI — research use only. Access is restricted to registered users; credentials are issued " "by the study team. LeukoAI is not a medical device and must not be used for diagnosis or " "treatment decisions. By logging in you agree to upload de-identified images only and to use " "results for research purposes only." ), )