diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000000000000000000000000000000000..b29a98509a8dd88a4c8827b3ec9d5ae56a17b932 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +*.pt filter=lfs diff=lfs merge=lfs -text +*.jpg filter=lfs diff=lfs merge=lfs -text +*.png filter=lfs diff=lfs merge=lfs -text diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000000000000000000000000000000000000..6d04bf1407f64f37e1702c28130240c7825c44c0 --- /dev/null +++ b/NOTICE @@ -0,0 +1,13 @@ +LeukoAI + +The original LeukoAI desktop application (toolv9) and its trained models were developed by +Sheikh Shakhbout Medical City (SSMC), Abu Dhabi, and Mohamed bin Zayed University of +Artificial Intelligence (MBZUAI). The application displays "Copyright (c) 2023 MBZUAI". + +legacy/toolv9_recovered.py was recovered by decompiling toolv9.exe and is kept for reference +and verification only. The files in weights/ are the original model weights with training +state removed. + +This repository is private. Public release or deployment of the code or models requires +written agreement from the rights holders. Published under the reference: +Naveed S, et al. J Med Artif Intell. 2025. doi:10.21037/jmai-24-74 diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..cafcdf0bb47d6818661c9b8e19d8934252281310 --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +--- +title: LeukoAI +emoji: 🔬 +colorFrom: red +colorTo: purple +sdk: gradio +sdk_version: 6.31.0 +python_version: "3.11" +app_file: app/app.py +pinned: false +license: cc-by-nc-nd-4.0 +short_description: Leukemia prediction from peripheral smear images (research) +--- + +LeukoAI research tool. Based on Naveed S et al., J Med Artif Intell 2025 (doi:10.21037/jmai-24-74). +Research use only; not a medical device. diff --git a/app/app.py b/app/app.py new file mode 100644 index 0000000000000000000000000000000000000000..9a2b1b47be4155fca6aa896e5f8d61f0af302ecc --- /dev/null +++ b/app/app.py @@ -0,0 +1,430 @@ +"""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")) +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 research prediction: acute or chronic leukemia, the likely subtype (ALL, AML, CLL, CML) + or no leukemia, with heatmaps showing where the model looked. For research and education only.

+
+
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 accept the terms of use.

+
3

Review

Read the research prediction, the warnings and the heatmaps on the most influential fields.

+
+""" + +PLACEHOLDER = """ +
+

Your research prediction 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-level prediction: leukemia is predicted 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 processed on Hugging Face servers, which are outside the UAE, only to produce your result. + They are deleted automatically within about an hour and are never used for training. No usage analytics + are collected.

+
+
+
+

Terms of use

+ +
+
+

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, .lk-cite ul { padding-left: 18px; margin:0; } +.lk-cite li { font-size:15px; line-height:1.6; color:var(--lk-muted); margin:0 0 6px; } +.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"
", + "
Research prediction · not for clinical use
", + 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}
", + "
Not a diagnosis. Research output only; it must not be used for patient care. " + "Confirm every case with standard laboratory work-up.
", + "
", + ] + 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 accept the terms of use (tick the box above the Analyse button).") + 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], gr.update()) + _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 am a healthcare professional or researcher. I will not use the output for " + "patient care. I am authorised to use these images and they contain no " + "patient identifiers. I accept the terms of use.") + 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=_access_list(), auth_message=AUTH_MESSAGE) diff --git a/assets/brand/LeukoAI_icon_512.png b/assets/brand/LeukoAI_icon_512.png new file mode 100644 index 0000000000000000000000000000000000000000..dd30d9c8d0496186761fe9f617a0a7451b642e09 --- /dev/null +++ b/assets/brand/LeukoAI_icon_512.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:47fa16a6eecba2b309abb2b6eee63496dcd523cd57750bf15cd9b302b4ed2d3c +size 37294 diff --git a/assets/brand/LeukoAI_logo.png b/assets/brand/LeukoAI_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..cc6a5f0b8f5e280c3431c12e6e38ad0b78ad458d --- /dev/null +++ b/assets/brand/LeukoAI_logo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2f27a4a63ae603ca08bd98d996c72c33e22128d924aa58c470cc6a0269e12653 +size 22642 diff --git a/assets/brand/LeukoAI_logo_dark.png b/assets/brand/LeukoAI_logo_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..137f049a17bfc6248f8ee807ed0207a1e29f8255 --- /dev/null +++ b/assets/brand/LeukoAI_logo_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6144926d3207e8eb4566bc117bd706bbf29d522fc670329a1de642463a28db3c +size 22583 diff --git a/assets/examples/aml/aml_field_01.jpg b/assets/examples/aml/aml_field_01.jpg new file mode 100644 index 0000000000000000000000000000000000000000..366527f198540ca830753e112c36e22ae83b2ff4 --- /dev/null +++ b/assets/examples/aml/aml_field_01.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9f29f9f12f19679ef74c0107fdd187cb77332a3c8bdaae35045a3e21f9d5d83e +size 84495 diff --git a/assets/examples/aml/aml_field_02.jpg b/assets/examples/aml/aml_field_02.jpg new file mode 100644 index 0000000000000000000000000000000000000000..48aea4ab49924d7e125cf82d9c46a2f5ad5db556 --- /dev/null +++ b/assets/examples/aml/aml_field_02.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a8a6bd32c0a1a1a383f835953bb962a7e90f762ba8e71bae105f8a5e4509abda +size 90276 diff --git a/assets/examples/aml/aml_field_03.jpg b/assets/examples/aml/aml_field_03.jpg new file mode 100644 index 0000000000000000000000000000000000000000..b01449e95d7361053c70a5c23fed0f814d7c3bfd --- /dev/null +++ b/assets/examples/aml/aml_field_03.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6e271d1184f84c170b19cd637c0670008fd91048405305a7b0ee495f05e74a45 +size 89218 diff --git a/assets/examples/aml/aml_field_04.jpg b/assets/examples/aml/aml_field_04.jpg new file mode 100644 index 0000000000000000000000000000000000000000..d33735116eb8d9d602503a8a4eb3beb53dde2842 --- /dev/null +++ b/assets/examples/aml/aml_field_04.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f757a8c78035e3fbacc2ebc8ee9aef95823211930d1b788a9a8954b8d7873ef5 +size 88399 diff --git a/assets/examples/aml/aml_field_05.jpg b/assets/examples/aml/aml_field_05.jpg new file mode 100644 index 0000000000000000000000000000000000000000..b80c0c7b7ff0c0c6df2a69832c14f0dc2e405d03 --- /dev/null +++ b/assets/examples/aml/aml_field_05.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f3f00e8de472a6682df729e193df3cf857c34aa782b3d5a1324e4550c3cc226f +size 79887 diff --git a/assets/examples/aml/aml_field_06.jpg b/assets/examples/aml/aml_field_06.jpg new file mode 100644 index 0000000000000000000000000000000000000000..25eba88c2f975f485943cd1412ba2df5dc5ed152 --- /dev/null +++ b/assets/examples/aml/aml_field_06.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6c897b3ac37cb233f18c07f92bdea63351afec85028e4a733fe69435ecd44443 +size 86917 diff --git a/assets/examples/aml/aml_field_07.jpg b/assets/examples/aml/aml_field_07.jpg new file mode 100644 index 0000000000000000000000000000000000000000..6f6741ef58a476e750667e319c99a729324c2a42 --- /dev/null +++ b/assets/examples/aml/aml_field_07.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:268b721398b1fead34bd2cd8ba6cd20d40196a2345f7e53a6cf3fb5be6053a40 +size 79213 diff --git a/assets/examples/aml/aml_field_08.jpg b/assets/examples/aml/aml_field_08.jpg new file mode 100644 index 0000000000000000000000000000000000000000..ee7c8afca6a5360aa27a498a90859eeda5057061 --- /dev/null +++ b/assets/examples/aml/aml_field_08.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d88b99626e8b786f441dc0928399ff69888b280ca84c51c77520a41a10f64e43 +size 76703 diff --git a/assets/examples/aml/aml_field_09.jpg b/assets/examples/aml/aml_field_09.jpg new file mode 100644 index 0000000000000000000000000000000000000000..fcff947b6b2028fc5d63420f3f5ae612bab2ad15 --- /dev/null +++ b/assets/examples/aml/aml_field_09.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:924109d20b83d43b6a96e038810850a8f10768429d24ca0ef7b07eae64f593c6 +size 75170 diff --git a/assets/examples/aml/aml_field_10.jpg b/assets/examples/aml/aml_field_10.jpg new file mode 100644 index 0000000000000000000000000000000000000000..bab907a694640f4c25a037c1b337c38d99fd366e --- /dev/null +++ b/assets/examples/aml/aml_field_10.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:85dc947c260e732807ff24e199c6306162f53c773d48a48869a388fd4f3d91df +size 81384 diff --git a/assets/examples/cll/cll_field_01.jpg b/assets/examples/cll/cll_field_01.jpg new file mode 100644 index 0000000000000000000000000000000000000000..97ff87fd74dbb3f7fad4775ef893a763f982e3f0 --- /dev/null +++ b/assets/examples/cll/cll_field_01.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:760d6c02efcb2a6e7c88525a1a6fbf60cae840dbceb478c446c777c53d19be0a +size 95121 diff --git a/assets/examples/cll/cll_field_02.jpg b/assets/examples/cll/cll_field_02.jpg new file mode 100644 index 0000000000000000000000000000000000000000..41ea41d2a104b9a91a761cb993fa7b93bc4f487e --- /dev/null +++ b/assets/examples/cll/cll_field_02.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:76eb2cd1c12023d67d4ca7696af083e0b82e969f55ef7d190cc1e7b262358ecd +size 89370 diff --git a/assets/examples/cll/cll_field_03.jpg b/assets/examples/cll/cll_field_03.jpg new file mode 100644 index 0000000000000000000000000000000000000000..1e753e6597a20e97f7f08580e3f38cf8f98d4ccd --- /dev/null +++ b/assets/examples/cll/cll_field_03.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5938686b8a95d0cd4cb1b648032d293198b0d77e232183d5a242382dcf7b668a +size 89883 diff --git a/assets/examples/cll/cll_field_04.jpg b/assets/examples/cll/cll_field_04.jpg new file mode 100644 index 0000000000000000000000000000000000000000..667af0fb56f1230b898eb3e96bb78328487d09e7 --- /dev/null +++ b/assets/examples/cll/cll_field_04.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2a36a067167a071a95a549337d9af14d6f96b1f838d8b8dd0997c3c5e397ec40 +size 85469 diff --git a/assets/examples/cll/cll_field_05.jpg b/assets/examples/cll/cll_field_05.jpg new file mode 100644 index 0000000000000000000000000000000000000000..154e8bf2035dc3b79a03ede8d5a34051018ea2e2 --- /dev/null +++ b/assets/examples/cll/cll_field_05.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:71453668128b8f660c9904b5fd89ddfa3c62772d8282cffaf688619bed8b03f6 +size 77130 diff --git a/assets/examples/cll/cll_field_06.jpg b/assets/examples/cll/cll_field_06.jpg new file mode 100644 index 0000000000000000000000000000000000000000..f253c8300666f64e41cf8f4cb7d5b660eb7c6be7 --- /dev/null +++ b/assets/examples/cll/cll_field_06.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0bd364a088bb14134145edf90d9342fb5f010e353a9cda10d41d276596b70405 +size 68364 diff --git a/assets/examples/cll/cll_field_07.jpg b/assets/examples/cll/cll_field_07.jpg new file mode 100644 index 0000000000000000000000000000000000000000..34dd46c28646565cf08fedfd1106260745bf076f --- /dev/null +++ b/assets/examples/cll/cll_field_07.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6086f6b230f76642bb6ad7c43712673600603559fc34e6d1a3b9a626c0e669d2 +size 66452 diff --git a/assets/examples/cll/cll_field_08.jpg b/assets/examples/cll/cll_field_08.jpg new file mode 100644 index 0000000000000000000000000000000000000000..9b3c0b38c25d2568cfd14a8ece60b1ae77a3b96e --- /dev/null +++ b/assets/examples/cll/cll_field_08.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d4945e68bf56f3d2c47684456a74e63f1f8b16d6d8efe157f0314d20e2ae4e3e +size 63442 diff --git a/assets/examples/cll/cll_field_09.jpg b/assets/examples/cll/cll_field_09.jpg new file mode 100644 index 0000000000000000000000000000000000000000..bd21f01e7e363639d41cefd68575d58936780185 --- /dev/null +++ b/assets/examples/cll/cll_field_09.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bc965af96ba16603df8c9454e21fd010552efe8dd38366fe72fd4fbb7d1f2354 +size 67011 diff --git a/assets/examples/cll/cll_field_10.jpg b/assets/examples/cll/cll_field_10.jpg new file mode 100644 index 0000000000000000000000000000000000000000..d9ca0c8a7d5bd2f3e41ec048e5c78c606fc86c57 --- /dev/null +++ b/assets/examples/cll/cll_field_10.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0d2e505ccb152d04ed0664ee030b7cc42bd2356cb09d3d04dca7fe2d70b16634 +size 77377 diff --git a/assets/examples/cml/cml_field_01.jpg b/assets/examples/cml/cml_field_01.jpg new file mode 100644 index 0000000000000000000000000000000000000000..b19e6baf3fde7306aac22310b0f9859b8debdead --- /dev/null +++ b/assets/examples/cml/cml_field_01.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9696fd2341fe812b63c3bdb6bdccfb080538572541beef2386b01570b83b37a9 +size 103340 diff --git a/assets/examples/cml/cml_field_02.jpg b/assets/examples/cml/cml_field_02.jpg new file mode 100644 index 0000000000000000000000000000000000000000..f4b459263dfc0fae8a3a39ae1a873c4eed15913a --- /dev/null +++ b/assets/examples/cml/cml_field_02.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:43131bdbbd399b6b0e0c3f6ee2a9eaa13ba1090a3f9eaedaa2c1328a84434288 +size 108805 diff --git a/assets/examples/cml/cml_field_03.jpg b/assets/examples/cml/cml_field_03.jpg new file mode 100644 index 0000000000000000000000000000000000000000..22f6623d9f5d870ef2baea2e7b200ec8661d192b --- /dev/null +++ b/assets/examples/cml/cml_field_03.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:472d3f7678bd7537d529207668a226f77881c890037b61595823f1d7df012c98 +size 93640 diff --git a/assets/examples/cml/cml_field_04.jpg b/assets/examples/cml/cml_field_04.jpg new file mode 100644 index 0000000000000000000000000000000000000000..2e6632870a6236f8c3ff6f4bc6c825ff2efbc9ef --- /dev/null +++ b/assets/examples/cml/cml_field_04.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dc29836516d618526caf83ab33fe2cfbaa4de0161ac94ef7511253b555b09bf2 +size 94185 diff --git a/assets/examples/cml/cml_field_05.jpg b/assets/examples/cml/cml_field_05.jpg new file mode 100644 index 0000000000000000000000000000000000000000..2bfda8bd354036b0706e0e12c2a0352c490a3edb --- /dev/null +++ b/assets/examples/cml/cml_field_05.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:332f4dbf534f3b476b6dd3132201bb97c17335768c9d320545a415a25d4324c6 +size 102212 diff --git a/assets/examples/cml/cml_field_06.jpg b/assets/examples/cml/cml_field_06.jpg new file mode 100644 index 0000000000000000000000000000000000000000..4b0d2f9b3aca767b3a3f75718490a3feca125f68 --- /dev/null +++ b/assets/examples/cml/cml_field_06.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:40181f891e8051179dcd185aa1b21b72bb030311df6e49e3b10b457d035d85ff +size 96844 diff --git a/assets/examples/cml/cml_field_07.jpg b/assets/examples/cml/cml_field_07.jpg new file mode 100644 index 0000000000000000000000000000000000000000..2c177a380dff50c457f1b618bff60c7286267e14 --- /dev/null +++ b/assets/examples/cml/cml_field_07.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fd798ef3f57c66d1444396f912a3f015ebfca752f6e365b68ec1e2e62bafcb3b +size 106917 diff --git a/assets/examples/cml/cml_field_08.jpg b/assets/examples/cml/cml_field_08.jpg new file mode 100644 index 0000000000000000000000000000000000000000..69b7e5be98c09283716302e0294612bc704db43e --- /dev/null +++ b/assets/examples/cml/cml_field_08.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6c90b261765a0eecedf5154fc4546f8d395ad827f1058141c10dc6d13ed0bb7a +size 102095 diff --git a/assets/examples/cml/cml_field_09.jpg b/assets/examples/cml/cml_field_09.jpg new file mode 100644 index 0000000000000000000000000000000000000000..f12bdd037854a4a05bd1a99d9e7e92cffbef120d --- /dev/null +++ b/assets/examples/cml/cml_field_09.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a50b9b237acf61156e4e06582d1f26edfeb4edc0dccbdc3bd6435f99b717f5f1 +size 82679 diff --git a/assets/examples/cml/cml_field_10.jpg b/assets/examples/cml/cml_field_10.jpg new file mode 100644 index 0000000000000000000000000000000000000000..b777f702ef92d688ca5acb9487bf9334337eba76 --- /dev/null +++ b/assets/examples/cml/cml_field_10.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9c8c7ddfd39e806a6192bc9870b3758e63ec5e56fc5b6fec19be838732833dea +size 88622 diff --git a/assets/examples/normal/normal_field_01.jpg b/assets/examples/normal/normal_field_01.jpg new file mode 100644 index 0000000000000000000000000000000000000000..31a26d06273e60cf0575719eece4e64fc5ed442e --- /dev/null +++ b/assets/examples/normal/normal_field_01.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a190df06501596ff15c49cd4e19fee3c1032c8756e01fb8ac8c77a3b5cb7e5ae +size 71567 diff --git a/assets/examples/normal/normal_field_02.jpg b/assets/examples/normal/normal_field_02.jpg new file mode 100644 index 0000000000000000000000000000000000000000..06fe9dcbff1ed1c75f079c8f8122509bf62b2c2c --- /dev/null +++ b/assets/examples/normal/normal_field_02.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a14bf7f0e6c24aaea2392d157fd5836a63a77d4d933b14ea65d77c75211e5fc6 +size 79772 diff --git a/assets/examples/normal/normal_field_03.jpg b/assets/examples/normal/normal_field_03.jpg new file mode 100644 index 0000000000000000000000000000000000000000..1f9b3ffa31038931feec9ea323a5bec703304346 --- /dev/null +++ b/assets/examples/normal/normal_field_03.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:421fce0980be2fd1a1a66e9622c1d36b3c125c2ae84fba9f292161ef3bd3242b +size 85817 diff --git a/assets/examples/normal/normal_field_04.jpg b/assets/examples/normal/normal_field_04.jpg new file mode 100644 index 0000000000000000000000000000000000000000..4502839bdd111e998b3f18b7ea967fb325ee37d0 --- /dev/null +++ b/assets/examples/normal/normal_field_04.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2dc8de691b1dd40f0bf0b4816d4aceb238fd0f1e78b8548918bf979b2b86fbff +size 79081 diff --git a/assets/examples/normal/normal_field_05.jpg b/assets/examples/normal/normal_field_05.jpg new file mode 100644 index 0000000000000000000000000000000000000000..78129885f22d46881e62ae598996788c0ddd8dfe --- /dev/null +++ b/assets/examples/normal/normal_field_05.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:93f3985cc4ad20ebc679ac8a956d5bbff0f350f8d7f760715c2cd962b301b85f +size 78723 diff --git a/assets/examples/normal/normal_field_06.jpg b/assets/examples/normal/normal_field_06.jpg new file mode 100644 index 0000000000000000000000000000000000000000..8c931813973853a8c8f0ebe819ddac5615f33d2e --- /dev/null +++ b/assets/examples/normal/normal_field_06.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:71a0915f0795ef021fc161de514a98ee734fe337ee0178fc576f32e9ea015a66 +size 67454 diff --git a/assets/examples/normal/normal_field_07.jpg b/assets/examples/normal/normal_field_07.jpg new file mode 100644 index 0000000000000000000000000000000000000000..962750e6804fe4f795c6920c74eca70ff61ac4da --- /dev/null +++ b/assets/examples/normal/normal_field_07.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9f7aaa016b9677f903cffe08c37bdf0c8490efa73721e2f32258b47a5b30972e +size 80906 diff --git a/assets/examples/normal/normal_field_08.jpg b/assets/examples/normal/normal_field_08.jpg new file mode 100644 index 0000000000000000000000000000000000000000..05bbd4492cf4ed4198302089c42daeed5d01fba2 --- /dev/null +++ b/assets/examples/normal/normal_field_08.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:224f1f6d2d23cffddbb7a1802074af4f72657d7354f40568c8afd9a4ee83e946 +size 93230 diff --git a/assets/examples/normal/normal_field_09.jpg b/assets/examples/normal/normal_field_09.jpg new file mode 100644 index 0000000000000000000000000000000000000000..800403f404af022364842d08f7274840b46d8627 --- /dev/null +++ b/assets/examples/normal/normal_field_09.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:756204376b1f12600c6bfcc66a6faa327834e72c47f99bd11d678ab88301e7ee +size 96602 diff --git a/assets/examples/normal/normal_field_10.jpg b/assets/examples/normal/normal_field_10.jpg new file mode 100644 index 0000000000000000000000000000000000000000..efd836dde916894ba2a6cbc53d6a43affd88b6d8 --- /dev/null +++ b/assets/examples/normal/normal_field_10.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:140c8b5f6c9eaad5f085b4f43cd16140816bc81c3b3311b64eab5125435ea4e9 +size 92996 diff --git a/assets/logo-mark.svg b/assets/logo-mark.svg new file mode 100644 index 0000000000000000000000000000000000000000..f1b37426718915f7fa2602d8b67d18382af38714 --- /dev/null +++ b/assets/logo-mark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/leukoai/__init__.py b/leukoai/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..05e56b28d7e801338819a595b947d42f64e72f22 --- /dev/null +++ b/leukoai/__init__.py @@ -0,0 +1,5 @@ +"""LeukoAI: leukemia prediction from peripheral blood smear images. Research use only.""" +__version__ = "1.1.0.dev0" + +from .models import DX1, DX2, DX3, load_models # noqa: F401 +from .pipeline import analyse_images, analyse_paths # noqa: F401 diff --git a/leukoai/experimental.py b/leukoai/experimental.py new file mode 100644 index 0000000000000000000000000000000000000000..e97444acfd90b34406ac42640bfb874c10b6d637 --- /dev/null +++ b/leukoai/experimental.py @@ -0,0 +1,66 @@ +"""EXPERIMENTAL case rules. Research comparison only; NOT used by the web app. + +Each rule changes exactly ONE step of the published rule (acute vs chronic), +so differences in results can be attributed to that step alone: + +* Leukemia vs no leukemia: unchanged (more than T images classed leukemic by Model 1). +* Subtype: unchanged (Model 3 image votes within ALL/AML or CLL/CML; ties -> ALL/CLL). +* Acute vs chronic: replaced, as below. + +Both rules are parameter-free and were specified on 29 Oct 2026, before any +further patient data were analysed, so they cannot have been tuned to the +validation cases. Motivation: in public LeukoAI images, 11 AML fields classed +chronic by Model 2 turned an AML folder into "CLL" under the published +"2 or more chronic images" step (docs/FINDINGS_v1.md). + +EXP_MAJORITY + Chronic if more images are classed chronic than acute by Model 2 + (all images counted, as in the published rule). Ties -> acute, because + missing acute leukemia is the more dangerous error. + +EXP_MEANPROB + Among images classed leukemic by Model 1, average Model 2's acute and + chronic probabilities. Chronic if mean P(chronic) > mean P(acute); + ties -> acute. Uses confidence rather than votes. +""" +from __future__ import annotations + +from .models import DX3 +from .rules import ImageResult + +RULES = ("exp_majority", "exp_meanprob") + + +def _leukemic(images, threshold): + return sum(1 for im in images if im.k1 == 1) > threshold + + +def _subtype(images, chronic: bool) -> str: + votes = [sum(1 for im in images if im.k3 == k) for k in range(5)] + sub = votes[2:4] if chronic else votes[:2] + return DX3[sub.index(max(sub)) + (2 if chronic else 0)] + + +def _finish(images, chronic: bool): + return ("CHRONIC LEUKEMIA" if chronic else "ACUTE LEUKEMIA"), _subtype(images, chronic) + + +def exp_majority(images: list[ImageResult], threshold: int = 3): + if not _leukemic(images, threshold): + return "NO LEUKEMIA", "NO LEUKEMIA" + acute = sum(1 for im in images if im.k2 == 0) + chronic = sum(1 for im in images if im.k2 == 1) + return _finish(images, chronic > acute) + + +def exp_meanprob(images: list[ImageResult], threshold: int = 3): + if not _leukemic(images, threshold): + return "NO LEUKEMIA", "NO LEUKEMIA" + leuk = [im for im in images if im.k1 == 1] + pa = sum(im.p2[0] for im in leuk) / len(leuk) + pc = sum(im.p2[1] for im in leuk) / len(leuk) + return _finish(images, pc > pa) + + +def run_all(images: list[ImageResult], threshold: int = 3) -> dict: + return {"exp_majority": exp_majority(images, threshold), "exp_meanprob": exp_meanprob(images, threshold)} diff --git a/leukoai/explain.py b/leukoai/explain.py new file mode 100644 index 0000000000000000000000000000000000000000..d995ca6623df8e179d9a9a8f0df522acc8506dde --- /dev/null +++ b/leukoai/explain.py @@ -0,0 +1,44 @@ +"""Grad-CAM heatmaps for DenseNet-121, mapped back onto the original image.""" +from __future__ import annotations + +import numpy as np +import torch +import torch.nn.functional as F +from PIL import Image + +from .preprocess import to_tensor + + +def gradcam(model, img: Image.Image, class_idx: int) -> np.ndarray: + """Return a heatmap in [0, 1] with the same size as ``img``.""" + device = next(model.parameters()).device + x = to_tensor(img).to(device) + with torch.enable_grad(): + feats = model.features(x) + feats.retain_grad() + out = F.relu(feats) + logits = model.classifier(torch.flatten(F.adaptive_avg_pool2d(out, 1), 1)) + model.zero_grad(set_to_none=True) + logits[0, class_idx].backward() + w = feats.grad.mean(dim=(2, 3), keepdim=True) + cam = F.relu((w * feats).sum(1, keepdim=True)) + cam = F.interpolate(cam, size=(448, 448), mode="bilinear", align_corners=False)[0, 0] + cam = cam.detach().cpu().numpy() + + # Undo the square padding: the image occupies a centred region of the padded square. + W, H = img.size + m = max(W, H) + left, top = int((m - W) / 2) / m * 448, int((m - H) / 2) / m * 448 + cam = cam[int(round(top)):int(round(448 - top)), int(round(left)):int(round(448 - left))] + cam = np.asarray(Image.fromarray(cam.astype(np.float32), mode="F").resize((W, H), Image.BILINEAR)) + rng = cam.max() - cam.min() + return (cam - cam.min()) / rng if rng > 0 else np.zeros_like(cam) + + +def overlay(img: Image.Image, cam: np.ndarray, alpha: float = 0.45) -> Image.Image: + """Blend a heatmap (red = most influential) onto the image.""" + base = np.asarray(img.convert("RGB"), dtype=np.float32) + c = np.clip(cam, 0, 1)[..., None] + heat = np.concatenate([np.clip(1.5 * c, 0, 1), np.clip(1.5 * c - 0.5, 0, 1) * 0.6, 0.2 * (1 - c)], axis=-1) * 255 + a = alpha * c + return Image.fromarray(np.clip(base * (1 - a) + heat * a, 0, 255).astype(np.uint8)) diff --git a/leukoai/models.py b/leukoai/models.py new file mode 100644 index 0000000000000000000000000000000000000000..48b3c50832ff9d1a5a5db0e7cdde4e6280dcd556 --- /dev/null +++ b/leukoai/models.py @@ -0,0 +1,73 @@ +"""Model definitions, class labels and weight loading for LeukoAI v1. + +The three models are DenseNet-121 classifiers recovered from the original +desktop application (toolv9, 2023). Weights in ``weights/`` are the original +checkpoints with training state removed; predictions are unchanged. +""" +from __future__ import annotations + +import hashlib +import os +from dataclasses import dataclass + +import torch +from torchvision.models import densenet121 + +# Class order exactly as in the original application. +DX1 = ["NO LEUKEMIA", "LEUKEMIA"] +DX2 = ["ACUTE LEUKEMIA", "CHRONIC LEUKEMIA", "NO LEUKEMIA", "REACTIVE CELLS"] +DX3 = ["ALL", "AML", "CLL", "CML", "NO LEUKEMIA"] + + +@dataclass(frozen=True) +class ModelSpec: + key: str + file: str + original_file: str + labels: tuple + sha256: str + + +SPECS = ( + ModelSpec("m1", "m1_leukemia_binary.pt", "del_new_bin_flip_jit_scale.ckpt", tuple(DX1), + "e5b65eb8b31a187908faf77867949cfa8f70a08b29c956458a0f59d42f14c873"), + ModelSpec("m2", "m2_acute_chronic_normal_reactive.pt", "del_new_acnr_flip_scale_jit.ckpt", tuple(DX2), + "304f436254a084d3921daaca41bbeb0ed9c8a712c6b7389ded779c49075a3a05"), + ModelSpec("m3", "m3_subtype_5class.pt", "new_five.ckpt", tuple(DX3), + "78b520597a19d03647815b0ae332630f4fdc790b0c0475afacce5d804cdd21a9"), +) + +DEFAULT_WEIGHTS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "weights") + + +def _sha256(path: str) -> str: + h = hashlib.sha256() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(1 << 20), b""): + h.update(chunk) + return h.hexdigest() + + +def _load_state_dict(spec: ModelSpec, weights_dir: str, verify: bool) -> dict: + slim = os.path.join(weights_dir, spec.file) + if os.path.exists(slim): + if verify and _sha256(slim) != spec.sha256: + raise ValueError(f"Checksum mismatch for {slim}; the weight file is not the released LeukoAI model.") + return torch.load(slim, map_location="cpu", weights_only=True) + original = os.path.join(weights_dir, spec.original_file) + if os.path.exists(original): # original toolv9 Lightning checkpoint + sd = torch.load(original, map_location="cpu", weights_only=True)["state_dict"] + return {k.replace("net.", "", 1): v for k, v in sd.items()} + raise FileNotFoundError(f"Neither {spec.file} nor {spec.original_file} found in {weights_dir}") + + +def load_models(weights_dir: str | None = None, device: str | None = None, verify: bool = True): + """Return ``{"m1": model, "m2": model, "m3": model}`` in eval mode.""" + weights_dir = weights_dir or DEFAULT_WEIGHTS_DIR + device = device or ("cuda" if torch.cuda.is_available() else "cpu") + models = {} + for spec in SPECS: + m = densenet121(num_classes=len(spec.labels)) + m.load_state_dict(_load_state_dict(spec, weights_dir, verify), strict=True) + models[spec.key] = m.eval().to(device) + return models diff --git a/leukoai/pipeline.py b/leukoai/pipeline.py new file mode 100644 index 0000000000000000000000000000000000000000..e39f2baa3bccd32222fb0418a8c5cbe2ee5d3f89 --- /dev/null +++ b/leukoai/pipeline.py @@ -0,0 +1,79 @@ +"""Analyse one case (all field images from one patient's smear).""" +from __future__ import annotations + +import os +from dataclasses import dataclass, field + +import torch + +from . import quality +from .preprocess import app_would_read, open_image, to_tensor +from .rules import CaseResult, Flag, ImageResult, display_subtype, published_rule, review_flags + +MODES = ("1.1", "original") + + +@dataclass +class CaseReport: + result: CaseResult + images: list + flags: list = field(default_factory=list) + subtype_display: str = "" + skipped: list = field(default_factory=list) + mode: str = "1.1" + + +def _predict(models, tensors, batch_size=8): + out = {k: [] for k in ("m1", "m2", "m3")} + with torch.no_grad(): + for i in range(0, len(tensors), batch_size): + x = torch.cat(tensors[i:i + batch_size]).to(next(models["m1"].parameters()).device) + for k in out: + out[k].extend(torch.softmax(models[k](x), 1).cpu().tolist()) + return out + + +def analyse_paths(paths: list[str], models, threshold: int = 3, mode: str = "1.1", batch_size: int = 8) -> CaseReport: + """Analyse image files. ``mode='original'`` reproduces the desktop app exactly, + including its file filter; ``mode='1.1'`` reads every valid image and adds checks.""" + if mode not in MODES: + raise ValueError(f"mode must be one of {MODES}") + named, skipped, app_skips = [], [], [] + for p in sorted(paths): + name = os.path.basename(p) + if os.path.isdir(p): + continue + img = open_image(p) + if img is None: + if os.path.splitext(name)[1].lower() not in (".csv", ".txt", ".ini", ".db", ".ds_store"): + skipped.append(name) + continue + if not app_would_read(p): + if mode == "original": + skipped.append(f"{name} (ignored by original app)") + continue + app_skips.append(name) + named.append((name, img)) + rep = analyse_images(named, models, threshold, mode, batch_size, skipped) + if app_skips: + rep.flags.append(Flag("APP_FILE_FILTER", "info", + f"{len(app_skips)} JPEG file(s) lack a JFIF/Exif header and would have been ignored " + f"by the original desktop app; they are included here.")) + return rep + + +def analyse_images(named_images: list, models, threshold: int = 3, mode: str = "1.1", + batch_size: int = 8, skipped: list | None = None) -> CaseReport: + """``named_images``: list of (name, PIL.Image).""" + skipped = list(skipped or []) + tensors = [to_tensor(img) for _, img in named_images] + probs = _predict(models, tensors, batch_size) if tensors else {"m1": [], "m2": [], "m3": []} + results = [] + for i, (name, img) in enumerate(named_images): + q = quality.check(img) if mode == "1.1" else {} + results.append(ImageResult(name, probs["m1"][i], probs["m2"][i], probs["m3"][i], q)) + res = published_rule(results, threshold) + if mode == "original": + return CaseReport(res, results, [], res.five_class, skipped, mode) + flags = review_flags(res, results, skipped) + return CaseReport(res, results, flags, display_subtype(res, flags), skipped, mode) diff --git a/leukoai/preprocess.py b/leukoai/preprocess.py new file mode 100644 index 0000000000000000000000000000000000000000..86c631718b3f945f5503d3fce9b880e4cb0e3086 --- /dev/null +++ b/leukoai/preprocess.py @@ -0,0 +1,69 @@ +"""Image preprocessing, identical to the original application. + +ToTensor -> zero-pad to square (centred) -> resize to 448 x 448, bilinear, +no antialiasing, no mean/std normalisation. + +``antialias=False`` reproduces tensor resizing in torchvision 0.13 +(PyTorch 1.12.1, the version bundled in toolv9). Newer torchvision versions +default to ``antialias=True``, which changes the input noticeably. +""" +from __future__ import annotations + +import torchvision.transforms.functional as F +from PIL import Image +from torchvision import transforms + +# Formats the original application accepted (it used imghdr: jpeg, png, tiff). +ACCEPTED_FORMATS = {"JPEG", "PNG", "TIFF", "MPO"} + + +def square_pad(t): + """Replica of the app's SquarePad, including its rounding of odd differences.""" + h, w = t.size()[-2:] + m = max(h, w) + top = int((m - h) / 2) + left = int((m - w) / 2) + # torchvision pad order: (left, top, right, bottom) + return F.pad(t, (left, top, left, top), 0, "constant") + + +TRANSFORM = transforms.Compose([ + transforms.ToTensor(), + square_pad, + transforms.Resize((448, 448), antialias=False), +]) + + +def app_would_read(path: str) -> bool: + """Replicates the app's file filter: Python 3.8 ``imghdr.what(path) in ('jpeg', 'png', 'tiff')``. + + Python 3.8 imghdr recognises a JPEG only if bytes 6-10 are 'JFIF' or 'Exif', + so JPEGs without those headers were silently ignored by the desktop app. + """ + try: + with open(path, "rb") as f: + h = f.read(32) + except OSError: + return False + if h[6:10] in (b"JFIF", b"Exif"): + return True # jpeg + if h.startswith(b"\211PNG\r\n\032\n"): + return True # png + if h[:6] in (b"GIF87a", b"GIF89a"): + return False # gif (tested before tiff) + return h[:2] in (b"MM", b"II") # tiff + + +def open_image(path: str): + """Open an image; return None if it is not a readable JPEG/PNG/TIFF.""" + try: + img = Image.open(path) + if img.format not in ACCEPTED_FORMATS: + return None + return img.convert("RGB") + except Exception: + return None + + +def to_tensor(img: Image.Image): + return TRANSFORM(img.convert("RGB")).unsqueeze(0) diff --git a/leukoai/quality.py b/leukoai/quality.py new file mode 100644 index 0000000000000000000000000000000000000000..e184072e93d50e6430728f459759d3c984bbe705 --- /dev/null +++ b/leukoai/quality.py @@ -0,0 +1,62 @@ +"""Basic check that an image looks like a stained peripheral smear field. + +This is a screening heuristic, not a trained detector. It catches clearly +wrong inputs (grayscale images, photos with green/yellow content, noise, +blank or very blurred images). In LeukoAI 1.1 it only raises a warning; the +image is still analysed so that results stay identical to the published tool. + +Thresholds were calibrated on 417 real 1000x smear fields from the public LeukoAI +Zenodo set (all five classes, several capture set-ups): none were flagged. Observed +ranges: saturation 0.09-0.31, off-palette 0.00, sharpness 10-16, contrast 24-51. +Re-check with ``scripts/calibrate_quality.py`` on images from other laboratories. +""" +from __future__ import annotations + +import numpy as np +from PIL import Image, ImageFilter + +THRESHOLDS = { + "min_saturation": 0.04, # mean HSV saturation; below this the image is effectively grayscale + "max_off_palette": 0.10, # fraction of coloured pixels with green/yellow/cyan hue + "min_sharpness": 4.0, # Laplacian standard deviation (0-255 scale) after downscaling + "max_sharpness": 40.0, # above this the image is dominated by noise or texture + "min_contrast": 12.0, # grayscale standard deviation; below this the image is blank +} + + +def measure(img: Image.Image) -> dict: + small = img.convert("RGB") + small.thumbnail((512, 512)) + hsv = np.asarray(small.convert("HSV"), dtype=np.float32) / 255.0 + h, s = hsv[..., 0], hsv[..., 1] + coloured = s > 0.15 + n_col = max(int(coloured.sum()), 1) + off = ((h > 0.10) & (h < 0.55) & coloured).sum() / n_col + gray = small.convert("L") + lap = np.asarray(gray.filter(ImageFilter.Kernel((3, 3), [0, 1, 0, 1, -4, 1, 0, 1, 0], 1, 128)), + dtype=np.float32) - 128.0 + return { + "saturation": round(float(s.mean()), 4), + "off_palette": round(float(off), 4), + "sharpness": round(float(lap.std()), 3), + "contrast": round(float(np.asarray(gray, dtype=np.float32).std()), 3), + "width": img.width, + "height": img.height, + } + + +def check(img: Image.Image, thresholds: dict | None = None) -> dict: + t = {**THRESHOLDS, **(thresholds or {})} + m = measure(img) + reasons = [] + if m["saturation"] < t["min_saturation"]: + reasons.append("no stain colour (grayscale)") + if m["off_palette"] > t["max_off_palette"]: + reasons.append("colours not typical of MGG stain") + if m["contrast"] < t["min_contrast"]: + reasons.append("blank or very low contrast") + if m["sharpness"] < t["min_sharpness"]: + reasons.append("very blurred") + if m["sharpness"] > t["max_sharpness"]: + reasons.append("noise-like texture") + return {**m, "ok": not reasons, "reasons": reasons} diff --git a/leukoai/rules.py b/leukoai/rules.py new file mode 100644 index 0000000000000000000000000000000000000000..723092f55e863ef9b23464fcae2ea5825d7b95b9 --- /dev/null +++ b/leukoai/rules.py @@ -0,0 +1,174 @@ +"""Case-level diagnosis rules. + +``published_rule`` is an exact replica of the rule in the original desktop +application (toolv9), which produced the published results. LeukoAI 1.1 keeps +this rule unchanged and adds ``review_flags``: warnings for situations where +the rule is known to be unreliable. Changes to the rule itself must wait +until they can be validated on new cases. +""" +from __future__ import annotations + +from dataclasses import dataclass, field + +from .models import DX1, DX2, DX3 + +# Images per patient in the published external validation: 2,973 / 64. +VALIDATION_MEAN_IMAGES = 46 +MIN_RECOMMENDED_IMAGES = 10 + + +@dataclass +class ImageResult: + name: str + p1: list # Model 1 probabilities, order DX1 + p2: list # Model 2 probabilities, order DX2 + p3: list # Model 3 probabilities, order DX3 + quality: dict = field(default_factory=dict) + + @property + def k1(self): + return max(range(len(self.p1)), key=self.p1.__getitem__) + + @property + def k2(self): + return max(range(len(self.p2)), key=self.p2.__getitem__) + + @property + def k3(self): + return max(range(len(self.p3)), key=self.p3.__getitem__) + + +@dataclass +class CaseResult: + three_class: str # ACUTE LEUKEMIA / CHRONIC LEUKEMIA / NO LEUKEMIA + five_class: str # ALL / AML / CLL / CML / NO LEUKEMIA (as the app reports it) + images_of_interest: list # up to 3 file names, as the app shows them + n_images: int + n_leukemic: int # Model 1 leukemia images + n_acute: int # Model 2 acute images + n_chronic: int # Model 2 chronic images + votes: dict # Model 3 image votes per class + threshold: int + flags: list = field(default_factory=list) + + +def published_rule(images: list[ImageResult], threshold: int = 3) -> CaseResult: + """Exact replica of toolv9 ``clicked()`` decision logic.""" + leukemia = 0 + bcls = [] # (rounded Model 1 prob, file) for leukemic images + scls = [[], [], [], []] # (rounded Model 2 prob, file) per Model 2 class + fcls = [0, 0, 0, 0, 0] # Model 3 votes + for im in images: + k1, k2, k3 = im.k1, im.k2, im.k3 + if k1 == 1: + leukemia += 1 + bcls.append((round(im.p1[k1], 2), im.name)) + scls[k2].append((round(im.p2[k2], 2), im.name)) + fcls[k3] += 1 + + interesting = [] + if leukemia > threshold: + if len(scls[1]) > 1: + sub = fcls[2:4] + five = DX3[sub.index(max(sub)) + 2] + three = "CHRONIC LEUKEMIA" + interesting = [x for _, x in sorted(scls[1], reverse=True)][:3] + else: + sub = fcls[:2] + five = DX3[sub.index(max(sub))] + three = "ACUTE LEUKEMIA" + interesting = [x for _, x in sorted(bcls, reverse=True)][:3] + else: + three = five = "NO LEUKEMIA" + + return CaseResult( + three_class=three, five_class=five, images_of_interest=interesting, + n_images=len(images), n_leukemic=leukemia, + n_acute=len(scls[0]), n_chronic=len(scls[1]), + votes=dict(zip(DX3, fcls)), threshold=threshold, + ) + + +@dataclass +class Flag: + code: str + severity: str # "info" | "caution" | "warning" + message: str + + +def review_flags(res: CaseResult, images: list[ImageResult], skipped: list[str] | None = None) -> list[Flag]: + """Warnings for situations where the published rule is known to be unreliable.""" + f: list[Flag] = [] + t = res.threshold + v = res.votes + + if res.n_images == 0: + f.append(Flag("NO_IMAGES", "warning", "No readable images were found, so no analysis was possible.")) + return f + + if res.n_images <= t: + f.append(Flag("TOO_FEW_IMAGES", "warning", + f"Only {res.n_images} image(s) were analysed. With the threshold set to {t}, " + f"the tool cannot report leukemia for {t} or fewer images, whatever they show.")) + elif res.n_images < MIN_RECOMMENDED_IMAGES: + f.append(Flag("FEW_IMAGES", "caution", + f"Only {res.n_images} images were analysed. The published validation used about " + f"{VALIDATION_MEAN_IMAGES} images per patient; results with fewer fields are less reliable.")) + + if abs(res.n_leukemic - (t + 1)) <= 1: + f.append(Flag("BORDERLINE_COUNT", "caution", + f"{res.n_leukemic} image(s) were classed as leukemic, close to the decision point " + f"(more than {t}). One image more or less would change the result.")) + + if res.three_class == "NO LEUKEMIA": + m3_leuk = {k: n for k, n in v.items() if k != "NO LEUKEMIA"} + top = max(m3_leuk, key=m3_leuk.get) + if m3_leuk[top] > v["NO LEUKEMIA"] and m3_leuk[top] > t: + f.append(Flag("MODEL_DISAGREEMENT", "warning", + f"The subtype model classed {m3_leuk[top]} of {res.n_images} images as {top}, " + f"although the screening model did not reach the leukemia threshold.")) + + if res.three_class == "CHRONIC LEUKEMIA" and res.n_acute > res.n_chronic: + f.append(Flag("ACUTE_CHRONIC_CONFLICT", "warning", + f"Reported as chronic because {res.n_chronic} images were classed chronic, although " + f"{res.n_acute} images were classed acute. The rule calls a case chronic once 2 images are chronic.")) + + if res.three_class == "ACUTE LEUKEMIA": + a, b = v["ALL"], v["AML"] + if a == b: + f.append(Flag("SUBTYPE_TIE", "warning", + f"ALL and AML received equal votes ({a} each). The subtype is undetermined; " + f"the original tool reports ALL in a tie.")) + else: + f.append(Flag("ALL_AML_LIMITATION", "caution", + "ALL vs AML separation was weak in validation (F1 0.36 for ALL, 0.57 for AML). " + "Treat the subtype as unconfirmed.")) + if res.five_class == "AML" or a == b: + f.append(Flag("APML_NOT_DETECTED", "info", + "APML was merged into AML during validation and is not reported separately. " + "If APML is suspected clinically, act on standard urgent pathways.")) + + if res.three_class == "CHRONIC LEUKEMIA": + c, m = v["CLL"], v["CML"] + if c == m: + f.append(Flag("SUBTYPE_TIE", "warning", + f"CLL and CML received equal votes ({c} each). The subtype is undetermined; " + f"the original tool reports CLL in a tie.")) + + poor = [im.name for im in images if im.quality and not im.quality.get("ok", True)] + if poor: + f.append(Flag("IMAGE_QUALITY", "warning", + f"{len(poor)} image(s) do not look like 1000x MGG-stained smear fields and were still " + f"included, as in the original tool: {', '.join(poor[:5])}{' ...' if len(poor) > 5 else ''}")) + + if skipped: + f.append(Flag("SKIPPED_FILES", "caution", + f"{len(skipped)} file(s) were not analysed: {', '.join(skipped[:5])}{' ...' if len(skipped) > 5 else ''}")) + return f + + +def display_subtype(res: CaseResult, flags: list[Flag]) -> str: + """Subtype wording for LeukoAI 1.1: ties are shown as undetermined instead of ALL/CLL.""" + if any(fl.code == "SUBTYPE_TIE" for fl in flags): + return "ALL or AML (undetermined)" if res.three_class == "ACUTE LEUKEMIA" else "CLL or CML (undetermined)" + return res.five_class diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..3d89bec2db6ae22e00e1a3d654fc41cd3c4c0105 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +--extra-index-url https://download.pytorch.org/whl/cpu +torch>=2.0 +torchvision>=0.15 +pillow>=9.0 +numpy>=1.23 diff --git a/weights/m1_leukemia_binary.pt b/weights/m1_leukemia_binary.pt new file mode 100644 index 0000000000000000000000000000000000000000..d0103f0db5dfafb7ea6e445dd65403e276588ba8 --- /dev/null +++ b/weights/m1_leukemia_binary.pt @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e5b65eb8b31a187908faf77867949cfa8f70a08b29c956458a0f59d42f14c873 +size 28400272 diff --git a/weights/m2_acute_chronic_normal_reactive.pt b/weights/m2_acute_chronic_normal_reactive.pt new file mode 100644 index 0000000000000000000000000000000000000000..8e8090754a892fc395e96648f8e9def959480da2 --- /dev/null +++ b/weights/m2_acute_chronic_normal_reactive.pt @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:304f436254a084d3921daaca41bbeb0ed9c8a712c6b7389ded779c49075a3a05 +size 28418790 diff --git a/weights/m3_subtype_5class.pt b/weights/m3_subtype_5class.pt new file mode 100644 index 0000000000000000000000000000000000000000..059649af731cb98af4c4ce254c52805839d41662 --- /dev/null +++ b/weights/m3_subtype_5class.pt @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:78b520597a19d03647815b0ae332630f4fdc790b0c0475afacce5d804cdd21a9 +size 28411827