LeukoAI deploy commited on
Commit
6e502b4
·
0 Parent(s):

Deploy a23fa57 from synav/LeukoAI-dev

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +3 -0
  2. NOTICE +13 -0
  3. README.md +16 -0
  4. app/app.py +430 -0
  5. assets/brand/LeukoAI_icon_512.png +3 -0
  6. assets/brand/LeukoAI_logo.png +3 -0
  7. assets/brand/LeukoAI_logo_dark.png +3 -0
  8. assets/examples/aml/aml_field_01.jpg +3 -0
  9. assets/examples/aml/aml_field_02.jpg +3 -0
  10. assets/examples/aml/aml_field_03.jpg +3 -0
  11. assets/examples/aml/aml_field_04.jpg +3 -0
  12. assets/examples/aml/aml_field_05.jpg +3 -0
  13. assets/examples/aml/aml_field_06.jpg +3 -0
  14. assets/examples/aml/aml_field_07.jpg +3 -0
  15. assets/examples/aml/aml_field_08.jpg +3 -0
  16. assets/examples/aml/aml_field_09.jpg +3 -0
  17. assets/examples/aml/aml_field_10.jpg +3 -0
  18. assets/examples/cll/cll_field_01.jpg +3 -0
  19. assets/examples/cll/cll_field_02.jpg +3 -0
  20. assets/examples/cll/cll_field_03.jpg +3 -0
  21. assets/examples/cll/cll_field_04.jpg +3 -0
  22. assets/examples/cll/cll_field_05.jpg +3 -0
  23. assets/examples/cll/cll_field_06.jpg +3 -0
  24. assets/examples/cll/cll_field_07.jpg +3 -0
  25. assets/examples/cll/cll_field_08.jpg +3 -0
  26. assets/examples/cll/cll_field_09.jpg +3 -0
  27. assets/examples/cll/cll_field_10.jpg +3 -0
  28. assets/examples/cml/cml_field_01.jpg +3 -0
  29. assets/examples/cml/cml_field_02.jpg +3 -0
  30. assets/examples/cml/cml_field_03.jpg +3 -0
  31. assets/examples/cml/cml_field_04.jpg +3 -0
  32. assets/examples/cml/cml_field_05.jpg +3 -0
  33. assets/examples/cml/cml_field_06.jpg +3 -0
  34. assets/examples/cml/cml_field_07.jpg +3 -0
  35. assets/examples/cml/cml_field_08.jpg +3 -0
  36. assets/examples/cml/cml_field_09.jpg +3 -0
  37. assets/examples/cml/cml_field_10.jpg +3 -0
  38. assets/examples/normal/normal_field_01.jpg +3 -0
  39. assets/examples/normal/normal_field_02.jpg +3 -0
  40. assets/examples/normal/normal_field_03.jpg +3 -0
  41. assets/examples/normal/normal_field_04.jpg +3 -0
  42. assets/examples/normal/normal_field_05.jpg +3 -0
  43. assets/examples/normal/normal_field_06.jpg +3 -0
  44. assets/examples/normal/normal_field_07.jpg +3 -0
  45. assets/examples/normal/normal_field_08.jpg +3 -0
  46. assets/examples/normal/normal_field_09.jpg +3 -0
  47. assets/examples/normal/normal_field_10.jpg +3 -0
  48. assets/logo-mark.svg +5 -0
  49. leukoai/__init__.py +5 -0
  50. leukoai/experimental.py +66 -0
.gitattributes ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ *.pt filter=lfs diff=lfs merge=lfs -text
2
+ *.jpg filter=lfs diff=lfs merge=lfs -text
3
+ *.png filter=lfs diff=lfs merge=lfs -text
NOTICE ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ LeukoAI
2
+
3
+ The original LeukoAI desktop application (toolv9) and its trained models were developed by
4
+ Sheikh Shakhbout Medical City (SSMC), Abu Dhabi, and Mohamed bin Zayed University of
5
+ Artificial Intelligence (MBZUAI). The application displays "Copyright (c) 2023 MBZUAI".
6
+
7
+ legacy/toolv9_recovered.py was recovered by decompiling toolv9.exe and is kept for reference
8
+ and verification only. The files in weights/ are the original model weights with training
9
+ state removed.
10
+
11
+ This repository is private. Public release or deployment of the code or models requires
12
+ written agreement from the rights holders. Published under the reference:
13
+ Naveed S, et al. J Med Artif Intell. 2025. doi:10.21037/jmai-24-74
README.md ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: LeukoAI
3
+ emoji: 🔬
4
+ colorFrom: red
5
+ colorTo: purple
6
+ sdk: gradio
7
+ sdk_version: 6.31.0
8
+ python_version: "3.11"
9
+ app_file: app/app.py
10
+ pinned: false
11
+ license: cc-by-nc-nd-4.0
12
+ short_description: Leukemia prediction from peripheral smear images (research)
13
+ ---
14
+
15
+ LeukoAI research tool. Based on Naveed S et al., J Med Artif Intell 2025 (doi:10.21037/jmai-24-74).
16
+ Research use only; not a medical device.
app/app.py ADDED
@@ -0,0 +1,430 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """LeukoAI 1.1 web application (research use only).
2
+
3
+ Run locally: python app/app.py then open http://127.0.0.1:7860
4
+ Hugging Face: see docs/DEPLOY.md
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import csv
9
+ import glob
10
+ import html
11
+ import os
12
+ import sys
13
+ import tempfile
14
+
15
+ import gradio as gr
16
+ import torch
17
+
18
+ ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
19
+ sys.path.insert(0, ROOT)
20
+ from leukoai import load_models # noqa: E402
21
+ from leukoai.explain import gradcam, overlay # noqa: E402
22
+ from leukoai.models import DX1, DX2, DX3 # noqa: E402
23
+ from leukoai.pipeline import analyse_paths # noqa: E402
24
+
25
+ torch.set_num_threads(max(1, os.cpu_count() or 1))
26
+ MODELS = load_models(device="cpu")
27
+ MAX_IMAGES = int(os.environ.get("LEUKOAI_MAX_IMAGES", "100"))
28
+ ASSETS = os.path.join(ROOT, "assets")
29
+
30
+ PAPER = "https://doi.org/10.21037/jmai-24-74"
31
+ CODE = "https://github.com/synav/LeukoAI"
32
+ DATA = "https://doi.org/10.5281/zenodo.17814776"
33
+ CARD = "https://github.com/synav/LeukoAI/blob/main/MODEL_CARD.md"
34
+
35
+ EXAMPLES = {
36
+ key: sorted(glob.glob(os.path.join(ASSETS, "examples", key, "*.jpg")))
37
+ for key in ("aml", "cll", "cml", "normal")
38
+ }
39
+
40
+
41
+ def _logo_svg() -> str:
42
+ path = os.path.join(ASSETS, "logo-mark.svg")
43
+ if not os.path.exists(path):
44
+ return ""
45
+ with open(path) as f:
46
+ return f.read().replace("<svg ", '<svg aria-hidden="true" focusable="false" ', 1)
47
+
48
+
49
+ LOGO_SVG = _logo_svg()
50
+
51
+
52
+ def _access_list():
53
+ """Logins from the LEUKOAI_ACCESS secret: "user:password;user2:password2".
54
+
55
+ On Hugging Face (SPACE_ID set) the app refuses to start without it, so it can never be
56
+ opened to the public by accident. Locally it runs without a login unless the variable is set.
57
+ """
58
+ raw = os.environ.get("LEUKOAI_ACCESS", "").strip()
59
+ pairs = [tuple(x.split(":", 1)) for x in raw.split(";") if ":" in x]
60
+ pairs = [(u.strip(), p.strip()) for u, p in pairs if u.strip() and p.strip()]
61
+ if not pairs and os.environ.get("SPACE_ID"):
62
+ raise SystemExit("LEUKOAI_ACCESS secret is not set: refusing to start without a login on Hugging Face.")
63
+ return pairs or None
64
+
65
+
66
+ AUTH_MESSAGE = ("LeukoAI is a research tool available to invited researchers, collaborators and journal "
67
+ "reviewers. To request access, contact Dr Syed Naveed (github.com/synav/LeukoAI). "
68
+ "Not a medical device; not for clinical use.")
69
+ FAVICON = os.path.join(ASSETS, "logo-mark.svg")
70
+
71
+ # ---------------------------------------------------------------- page text
72
+
73
+ HEAD = """
74
+ <meta name="description" content="LeukoAI: research tool that predicts leukemia and its subtype from peripheral blood smear images. Based on Naveed S et al., J Med Artif Intell 2025.">
75
+ <meta property="og:title" content="LeukoAI: leukemia prediction from blood smear images">
76
+ <meta property="og:description" content="Upload the microscope fields from one smear and get a case-level result with heatmaps. Research use only.">
77
+ <link rel="preconnect" href="https://fonts.googleapis.com">
78
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
79
+ <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@500;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
80
+ <script>
81
+ (function () {
82
+ var u = new URL(window.location.href);
83
+ if (u.searchParams.get('__theme') !== 'light') { u.searchParams.set('__theme', 'light'); window.location.replace(u.toString()); }
84
+ })();
85
+ </script>
86
+ """
87
+
88
+ HEADER = f"""
89
+ <header class="lk-nav">
90
+ <a class="lk-brand" href="#top" aria-label="LeukoAI home">
91
+ <span class="lk-mark">{LOGO_SVG}</span><span class="lk-wordmark">Leuko<span>AI</span></span>
92
+ </a>
93
+ <nav class="lk-links" aria-label="Main">
94
+ <a href="#tool">Try it</a>
95
+ <a href="#about">About</a>
96
+ <a href="{PAPER}" target="_blank" rel="noopener">Paper</a>
97
+ <a href="{CODE}" target="_blank" rel="noopener">Code</a>
98
+ <a href="{DATA}" target="_blank" rel="noopener">Data</a>
99
+ </nav>
100
+ </header>
101
+ """
102
+
103
+ HERO = """
104
+ <section class="lk-hero" id="top">
105
+ <p class="lk-eyebrow">LeukoAI 1.1 · Research tool</p>
106
+ <h1>Leukemia prediction from peripheral blood smear images</h1>
107
+ <p class="lk-lead">Upload the microscope fields from one patient's smear. LeukoAI classifies every field and
108
+ combines them into one research prediction: acute or chronic leukemia, the likely subtype (ALL, AML, CLL, CML)
109
+ or no leukemia, with heatmaps showing where the model looked. For research and education only.</p>
110
+ <div class="lk-stats">
111
+ <div><span class="lk-num">417/417</span><span>images give identical results to the published desktop tool</span></div>
112
+ <div><span class="lk-num">0/417</span><span>real smear fields falsely flagged by the image check</span></div>
113
+ <div><span class="lk-num">No scanner</span><span>works on ordinary photos taken at the microscope</span></div>
114
+ </div>
115
+ </section>
116
+ """
117
+
118
+ STEPS = """
119
+ <section class="lk-steps" aria-label="How it works">
120
+ <div><span class="lk-step">1</span><h3>Capture</h3><p>Photograph 20 or more fields of one smear at 1000× (oil immersion, May-Grünwald-Giemsa stain).</p></div>
121
+ <div><span class="lk-step">2</span><h3>Upload</h3><p>Select all the images at once (Ctrl+A in the folder) and accept the terms of use.</p></div>
122
+ <div><span class="lk-step">3</span><h3>Review</h3><p>Read the research prediction, the warnings and the heatmaps on the most influential fields.</p></div>
123
+ </section>
124
+ """
125
+
126
+ PLACEHOLDER = """
127
+ <div class="lk-empty">
128
+ <p class="lk-empty-title">Your research prediction will appear here</p>
129
+ <p>Upload one patient's smear fields, or load an example case, then select <b>Analyse case</b>.
130
+ Analysis takes about 1–2 seconds per image.</p>
131
+ </div>
132
+ """
133
+
134
+ ABOUT = f"""
135
+ <section class="lk-about" id="about">
136
+ <h2>About LeukoAI</h2>
137
+ <div class="lk-grid">
138
+ <div>
139
+ <h3>How it works</h3>
140
+ <p>Three DenseNet-121 models work in sequence: the first screens each field for leukemia, the second
141
+ separates acute from chronic, and the third suggests the subtype. A rule then combines the fields into one
142
+ case-level prediction: leukemia is predicted when more than 3 fields are classed leukemic.</p>
143
+ </div>
144
+ <div>
145
+ <h3>How it was validated</h3>
146
+ <p>The published study used 7,255 images from 162 adult patients. On 64 external patients the
147
+ acute / chronic / no-leukemia result reached an F1 score of 0.94 (accuracy 0.92); the five-class subtype
148
+ result reached a macro-F1 of 0.77. This web version reproduces the original desktop tool exactly.</p>
149
+ </div>
150
+ <div>
151
+ <h3>Limitations</h3>
152
+ <ul>
153
+ <li>Single centre, adult patients, 1000× MGG-stained peripheral smears only.</li>
154
+ <li>ALL vs AML separation is weak (F1 0.36 for ALL); treat the subtype as unconfirmed.</li>
155
+ <li>APML is not reported separately. Scores are not calibrated probabilities.</li>
156
+ </ul>
157
+ </div>
158
+ <div>
159
+ <h3>Privacy</h3>
160
+ <p>Images are processed on Hugging Face servers, which are outside the UAE, only to produce your result.
161
+ They are deleted automatically within about an hour and are never used for training. No usage analytics
162
+ are collected.</p>
163
+ </div>
164
+ </div>
165
+ <div class="lk-cite" id="terms">
166
+ <h3>Terms of use</h3>
167
+ <ul>
168
+ <li>LeukoAI is a research and education tool. It is not a medical device, has not been approved by any
169
+ regulator, and must not be used for diagnosis, screening or treatment decisions.</li>
170
+ <li>Access is limited to invited researchers, collaborators and journal reviewers, and may be withdrawn at any time.</li>
171
+ <li>You must be authorised to use any images you upload, and they must be de-identified: no names, record
172
+ numbers, dates or other identifiers in the image or file name.</li>
173
+ <li>You are responsible for complying with the data-protection and health-data laws that apply to you,
174
+ including rules on transferring health data outside your country.</li>
175
+ <li>The tool is provided as is, without warranty of any kind; predictions may be wrong.</li>
176
+ </ul>
177
+ </div>
178
+ <div class="lk-cite">
179
+ <h3>Cite</h3>
180
+ <p>Naveed S, et al. Novel hierarchical deep learning models predict type of leukemia from whole slide
181
+ microscopic images of peripheral blood. <i>Journal of Medical Artificial Intelligence</i>. 2025.
182
+ <a href="{PAPER}" target="_blank" rel="noopener">doi:10.21037/jmai-24-74</a></p>
183
+ <p class="lk-small">Model card: <a href="{CARD}" target="_blank" rel="noopener">github.com/synav/LeukoAI</a> ·
184
+ Public image dataset: <a href="{DATA}" target="_blank" rel="noopener">Zenodo 10.5281/zenodo.17814776</a></p>
185
+ </div>
186
+ </section>
187
+ """
188
+
189
+ FOOTER = """
190
+ <footer class="lk-footer">
191
+ <p><b>Research use only.</b> LeukoAI is not a medical device and must not be used for diagnosis or treatment
192
+ decisions. See the <a href="#terms">terms of use</a>.</p>
193
+ <p>Developed by Dr Syed Naveed, FRCPath, and team at Sheikh Shakhbout Medical City, Abu Dhabi, with the
194
+ Mohamed bin Zayed University of Artificial Intelligence.</p>
195
+ </footer>
196
+ """
197
+
198
+ CSS = """
199
+ :root, .gradio-container { --lk-ink:#14212E; --lk-muted:#55606E; --lk-line:#DCE3EA; --lk-ground:#F4F6F9;
200
+ --lk-brand:#2B6698; --lk-brand-dark:#1D4A70; --lk-card:#FFFFFF; }
201
+ html, body, gradio-app, .gradio-container { background: var(--lk-ground) !important; }
202
+ .gradio-container { max-width: min(1180px, 100vw) !important; margin: 0 auto !important; padding: 0 clamp(10px, 2.5vw, 16px) !important; overflow-x: hidden;
203
+ font-family: 'IBM Plex Sans', system-ui, sans-serif !important; color: var(--lk-ink); }
204
+ .lk-nav { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:12px;
205
+ padding: 18px 0 14px; border-bottom: 1px solid var(--lk-line); }
206
+ .lk-brand { display:inline-flex; align-items:center; gap:10px; text-decoration:none; }
207
+ .lk-mark { width: clamp(34px, 4.5vw, 42px); height: clamp(34px, 4.5vw, 42px); display:inline-block; }
208
+ .lk-mark svg { width:100%; height:100%; display:block; }
209
+ .lk-wordmark { font-weight:700; font-size: clamp(22px, 2.8vw, 27px); letter-spacing:-0.01em; color:var(--lk-ink); }
210
+ .lk-wordmark span { color: var(--lk-brand); }
211
+ .lk-links { display:flex; flex-wrap:wrap; gap: 4px 22px; }
212
+ .lk-links a { color: var(--lk-ink); text-decoration:none; font-weight:500; font-size:15px; padding:8px 0; }
213
+ .lk-links a:hover { color: var(--lk-brand); }
214
+ .lk-hero { padding: clamp(28px, 5vw, 44px) 0 clamp(20px, 3vw, 28px); }
215
+ .lk-eyebrow { font-family:'IBM Plex Mono', monospace; font-size:13px; letter-spacing:.08em; text-transform:uppercase;
216
+ color: var(--lk-brand); font-weight:600; margin:0 0 12px; }
217
+ .lk-hero h1 { font-size: clamp(30px, 4.4vw, 48px); line-height:1.1; letter-spacing:-.02em; margin:0 0 16px;
218
+ font-weight:700; color:var(--lk-ink); max-width: 860px; }
219
+ .lk-lead { font-size: clamp(16px, 2vw, 18px); line-height:1.55; color:var(--lk-muted); max-width: 760px; margin:0 0 28px; }
220
+ .lk-stats { display:grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap:16px;
221
+ border-top: 2px solid var(--lk-ink); padding-top: 20px; }
222
+ .lk-stats div { display:flex; flex-direction:column; gap:6px; }
223
+ .lk-stats span { font-size:15px; line-height:1.45; color:var(--lk-muted); }
224
+ .lk-stats .lk-num { font-family:'IBM Plex Mono', monospace; font-size:28px; font-weight:600; color:var(--lk-brand); }
225
+ .lk-steps { display:grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap:16px; margin: 8px 0 28px; }
226
+ .lk-steps > div { background: var(--lk-card); border:1px solid var(--lk-line); border-radius:10px; padding:18px 20px; }
227
+ .lk-steps h3 { margin: 10px 0 6px; font-size:17px; color:var(--lk-ink); }
228
+ .lk-steps p { margin:0; color:var(--lk-muted); font-size:15px; line-height:1.5; }
229
+ .lk-step { display:inline-flex; align-items:center; justify-content:center; width:30px; height:30px; border-radius:50%;
230
+ background: var(--lk-brand); color:#fff; font-weight:600; font-size:15px; }
231
+ #tool { gap: 20px; scroll-margin-top: 16px; }
232
+ .lk-panel { background: var(--lk-card) !important; border:1px solid var(--lk-line) !important; border-radius:12px !important;
233
+ padding: clamp(12px, 2vw, 18px) !important; max-width: 100% !important; }
234
+ .gradio-container .main, .gradio-container .app { max-width: 100% !important; min-width: 0 !important; }
235
+ #tool { flex-wrap: wrap !important; max-width: 100% !important; }
236
+ #tool > .lk-panel:first-child { flex: 5 1 340px !important; min-width: min(340px, 100%) !important; }
237
+ #tool > .lk-panel:last-child { flex: 7 1 340px !important; min-width: min(340px, 100%) !important; }
238
+ .lk-panel-title { margin:0 0 4px; font-size:18px; font-weight:600; color:var(--lk-ink); }
239
+ .lk-panel-sub { margin:0 0 10px; font-size:14px; color:var(--lk-muted); line-height:1.5; }
240
+ .lk-ex-label { font-size:14px; color:var(--lk-muted); margin: 6px 0 0; }
241
+ .lk-empty { border: 2px dashed var(--lk-line); border-radius:10px; padding: 36px 24px; text-align:center; color:var(--lk-muted); }
242
+ .lk-empty-title { font-size:18px; font-weight:600; color:var(--lk-ink); margin:0 0 6px; }
243
+ .lk-result { border-radius:10px; padding:18px 20px; margin-bottom:12px; border:2px solid; background:#fff; }
244
+ .lk-result .lk-label { font-family:'IBM Plex Mono', monospace; font-size:12px; letter-spacing:.06em; text-transform:uppercase; color:var(--lk-muted); }
245
+ .lk-result .lk-dx { font-size:30px; font-weight:700; margin:4px 0; letter-spacing:-.01em; }
246
+ .lk-result .lk-sub { font-size:18px; color:var(--lk-ink); }
247
+ .lk-result .lk-meta { font-size:14px; color:var(--lk-muted); margin-top:10px; line-height:1.5; }
248
+ .lk-flag { padding:10px 14px; margin:8px 0; border-radius:8px; font-size:14px; line-height:1.5; color:#101828; }
249
+ .lk-about { padding: 40px 0 8px; scroll-margin-top: 16px; }
250
+ .lk-about h2 { font-size: 30px; margin:0 0 20px; color:var(--lk-ink); letter-spacing:-.01em; }
251
+ .lk-grid { display:grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 20px 32px; }
252
+ .lk-about h3 { font-size:17px; margin: 0 0 8px; color:var(--lk-ink); }
253
+ .lk-about p, .lk-about li { font-size:15px; line-height:1.6; color:var(--lk-muted); margin:0 0 8px; }
254
+ .lk-about ul, .lk-cite ul { padding-left: 18px; margin:0; }
255
+ .lk-cite li { font-size:15px; line-height:1.6; color:var(--lk-muted); margin:0 0 6px; }
256
+ .lk-about a, .lk-footer a { color: var(--lk-brand); }
257
+ .lk-cite { margin-top: 20px; padding: 18px 20px; background: var(--lk-card); border:1px solid var(--lk-line); border-radius:10px; }
258
+ .lk-small { font-size:14px !important; }
259
+ .lk-footer { border-top:1px solid var(--lk-line); margin-top: 32px; padding: 20px 0 8px; }
260
+ .lk-footer p { font-size:14px; color:var(--lk-muted); margin: 0 0 8px; line-height:1.5; }
261
+ """
262
+
263
+ THEME = gr.themes.Base(
264
+ primary_hue=gr.themes.Color(c50="#EEF4FA", c100="#D6E5F2", c200="#AECBE5", c300="#7FAAD3", c400="#4F88BE",
265
+ c500="#2B6698", c600="#245A87", c700="#1D4A70", c800="#173B59", c900="#112C43",
266
+ c950="#0B1E2E"),
267
+ neutral_hue="slate",
268
+ font=[gr.themes.GoogleFont("IBM Plex Sans"), "system-ui", "sans-serif"],
269
+ font_mono=[gr.themes.GoogleFont("IBM Plex Mono"), "monospace"],
270
+ radius_size="md",
271
+ ).set(button_primary_background_fill="#2B6698", button_primary_background_fill_hover="#1D4A70",
272
+ button_primary_text_color="#FFFFFF")
273
+
274
+ # ---------------------------------------------------------------- analysis
275
+
276
+ SEV_STYLE = {
277
+ "warning": ("#B42318", "#FEF3F2", "Warning"),
278
+ "caution": ("#93370D", "#FFFAEB", "Caution"),
279
+ "info": ("#175CD3", "#EFF8FF", "Note"),
280
+ }
281
+ DX_COLOUR = {"NO LEUKEMIA": "#027A48", "ACUTE LEUKEMIA": "#B42318", "CHRONIC LEUKEMIA": "#93370D"}
282
+
283
+
284
+ def _result_html(rep) -> str:
285
+ r = rep.result
286
+ colour = DX_COLOUR[r.three_class]
287
+ title = r.three_class.capitalize().replace("leukemia", "leukemia")
288
+ subtype = "" if r.three_class == "NO LEUKEMIA" else (
289
+ f"<div class='lk-sub'>Likely subtype: <b>{html.escape(rep.subtype_display)}</b></div>")
290
+ votes = " · ".join(f"{k.title() if k == 'NO LEUKEMIA' else k} {v}" for k, v in r.votes.items())
291
+ parts = [
292
+ f"<div class='lk-result' style='border-color:{colour}'>",
293
+ "<div class='lk-label'>Research prediction · not for clinical use</div>",
294
+ f"<div class='lk-dx' style='color:{colour}'>{html.escape(title)}</div>{subtype}",
295
+ f"<div class='lk-meta'>{r.n_images} fields analysed · {r.n_leukemic} classed leukemic "
296
+ f"(needs more than {r.threshold}) · acute {r.n_acute}, chronic {r.n_chronic}<br>Subtype votes: {votes}</div>",
297
+ "<div class='lk-meta'><b>Not a diagnosis.</b> Research output only; it must not be used for patient care. "
298
+ "Confirm every case with standard laboratory work-up.</div>",
299
+ "</div>",
300
+ ]
301
+ for fl in sorted(rep.flags, key=lambda f: ["warning", "caution", "info"].index(f.severity)):
302
+ fg, bg, label = SEV_STYLE[fl.severity]
303
+ parts.append(f"<div class='lk-flag' style='background:{bg};border-left:4px solid {fg}'>"
304
+ f"<b style='color:{fg}'>{label}:</b> {html.escape(fl.message)}</div>")
305
+ return "\n".join(parts)
306
+
307
+
308
+ def _per_image_rows(rep):
309
+ rows = []
310
+ for im in rep.images:
311
+ q = im.quality or {}
312
+ rows.append([im.name, DX1[im.k1].capitalize(), round(im.p1[1], 3), DX2[im.k2].capitalize(), DX3[im.k3],
313
+ round(max(im.p3), 3), "OK" if q.get("ok", True) else "; ".join(q.get("reasons", []))])
314
+ return rows
315
+
316
+
317
+ def _write_csv(rep) -> str:
318
+ fd, path = tempfile.mkstemp(prefix="leukoai_", suffix=".csv")
319
+ with os.fdopen(fd, "w", newline="") as f:
320
+ w = csv.writer(f)
321
+ w.writerow(["image"] + [f"m1_{c}" for c in DX1] + [f"m2_{c}" for c in DX2] + [f"m3_{c}" for c in DX3]
322
+ + ["quality_ok", "quality_reasons"])
323
+ for im in rep.images:
324
+ q = im.quality or {}
325
+ w.writerow([im.name] + [round(x, 5) for x in im.p1 + im.p2 + im.p3]
326
+ + [q.get("ok", ""), "; ".join(q.get("reasons", []))])
327
+ r = rep.result
328
+ w.writerow([])
329
+ w.writerow(["case_result", r.three_class, "subtype", rep.subtype_display, "threshold", r.threshold])
330
+ for fl in rep.flags:
331
+ w.writerow(["flag", fl.code, fl.severity, fl.message])
332
+ return path
333
+
334
+
335
+ def analyse(files, threshold, show_heatmaps, consent):
336
+ if not consent:
337
+ raise gr.Error("Please accept the terms of use (tick the box above the Analyse button).")
338
+ if not files:
339
+ raise gr.Error("Please upload the field images from one patient's smear, or load an example case.")
340
+ paths = [f if isinstance(f, str) else f.name for f in files]
341
+ if len(paths) > MAX_IMAGES:
342
+ raise gr.Error(f"Please upload at most {MAX_IMAGES} images per case.")
343
+
344
+ rep = analyse_paths(paths, MODELS, threshold=int(threshold), mode="1.1")
345
+ by_name = {os.path.basename(p): p for p in paths}
346
+
347
+ gallery = []
348
+ if rep.result.images_of_interest:
349
+ chronic = rep.result.three_class == "CHRONIC LEUKEMIA"
350
+ # Model 2 (acute/chronic) heatmaps localise leukocytes well; Model 1 is usually
351
+ # saturated near 100% on leukemic fields, which makes its gradients uninformative.
352
+ model, cls = MODELS["m2"], (1 if chronic else 0)
353
+ from PIL import Image
354
+ for name in rep.result.images_of_interest:
355
+ img = Image.open(by_name[name]).convert("RGB")
356
+ if show_heatmaps:
357
+ img = overlay(img, gradcam(model, img, cls))
358
+ gallery.append((img, name))
359
+
360
+ headers = ["Image", "Screening", "Leukemia score", "Category", "Subtype vote", "Subtype score", "Image check"]
361
+ return (_result_html(rep), gr.update(value=gallery, visible=bool(gallery)),
362
+ gr.update(visible=True), gr.update(value=_per_image_rows(rep), headers=headers),
363
+ gr.update(value=_write_csv(rep), visible=True))
364
+
365
+
366
+ _HIDDEN = (PLACEHOLDER, gr.update(value=[], visible=False), gr.update(visible=False), None,
367
+ gr.update(value=None, visible=False))
368
+
369
+
370
+ def load_example(key):
371
+ return (EXAMPLES[key], gr.update()) + _HIDDEN
372
+
373
+
374
+ def clear():
375
+ return (None, False) + _HIDDEN
376
+
377
+
378
+ # ---------------------------------------------------------------- layout
379
+
380
+ with gr.Blocks(title="LeukoAI · Leukemia prediction from blood smears", delete_cache=(1800, 3600),
381
+ analytics_enabled=False) as demo:
382
+ gr.HTML(HEADER)
383
+ gr.HTML(HERO)
384
+ gr.HTML(STEPS)
385
+ with gr.Row(elem_id="tool", equal_height=False):
386
+ with gr.Column(scale=5, min_width=320, elem_classes="lk-panel"):
387
+ gr.HTML("<p class='lk-panel-title'>Upload one patient's smear</p>"
388
+ "<p class='lk-panel-sub'>All fields from a single smear, 1000× MGG. JPEG, PNG or TIFF; up to "
389
+ f"{MAX_IMAGES} images.</p>")
390
+ files = gr.File(label="Field images", file_count="multiple",
391
+ file_types=["image", ".tif", ".tiff"], height=220)
392
+ gr.HTML("<p class='lk-ex-label'>No images to hand? Load a public example case "
393
+ "(from the LeukoAI Zenodo dataset):</p>")
394
+ with gr.Row():
395
+ ex_buttons = {k: gr.Button(label, size="sm", variant="secondary", min_width=70)
396
+ for k, label in (("aml", "AML"), ("cll", "CLL"), ("cml", "CML"), ("normal", "Normal"))}
397
+ with gr.Accordion("Settings", open=False):
398
+ threshold = gr.Slider(0, 10, value=3, step=1, label="Image count threshold",
399
+ info="Leukemia is reported when more than this many fields are classed "
400
+ "leukemic. Published setting: 3.")
401
+ show_heatmaps = gr.Checkbox(value=True, label="Show heatmaps on the most influential fields")
402
+ consent = gr.Checkbox(label="I am a healthcare professional or researcher. I will not use the output for "
403
+ "patient care. I am authorised to use these images and they contain no "
404
+ "patient identifiers. I accept the terms of use.")
405
+ with gr.Row():
406
+ run = gr.Button("Analyse case", variant="primary", scale=3)
407
+ reset = gr.Button("Clear", variant="secondary", scale=1)
408
+ with gr.Column(scale=7, min_width=320, elem_classes="lk-panel"):
409
+ result = gr.HTML(PLACEHOLDER)
410
+ gallery = gr.Gallery(label="Most influential fields (warm colours = where the model looked)",
411
+ columns=3, rows=1, height=260, object_fit="cover", visible=False)
412
+ with gr.Accordion("Per-field results", open=False, visible=False) as table_box:
413
+ table = gr.Dataframe(interactive=False, wrap=True)
414
+ download = gr.File(label="Download all results (CSV)", visible=False)
415
+ gr.HTML(ABOUT)
416
+ gr.HTML(FOOTER)
417
+
418
+ outputs = [result, gallery, table_box, table, download]
419
+ run.click(analyse, [files, threshold, show_heatmaps, consent], outputs)
420
+ for k, b in ex_buttons.items():
421
+ b.click(lambda k=k: load_example(k), None, [files, consent] + outputs)
422
+ reset.click(clear, None, [files, consent] + outputs)
423
+
424
+ if __name__ == "__main__":
425
+ demo.queue(max_size=20).launch(
426
+ server_name=os.environ.get("HOST") or os.environ.get("GRADIO_SERVER_NAME") or "127.0.0.1",
427
+ server_port=int(os.environ.get("PORT") or os.environ.get("GRADIO_SERVER_PORT") or 7860),
428
+ theme=THEME, css=CSS, head=HEAD, allowed_paths=[ASSETS],
429
+ favicon_path=FAVICON if os.path.exists(FAVICON) else None,
430
+ auth=_access_list(), auth_message=AUTH_MESSAGE)
assets/brand/LeukoAI_icon_512.png ADDED

Git LFS Details

  • SHA256: 47fa16a6eecba2b309abb2b6eee63496dcd523cd57750bf15cd9b302b4ed2d3c
  • Pointer size: 130 Bytes
  • Size of remote file: 37.3 kB
assets/brand/LeukoAI_logo.png ADDED

Git LFS Details

  • SHA256: 2f27a4a63ae603ca08bd98d996c72c33e22128d924aa58c470cc6a0269e12653
  • Pointer size: 130 Bytes
  • Size of remote file: 22.6 kB
assets/brand/LeukoAI_logo_dark.png ADDED

Git LFS Details

  • SHA256: 6144926d3207e8eb4566bc117bd706bbf29d522fc670329a1de642463a28db3c
  • Pointer size: 130 Bytes
  • Size of remote file: 22.6 kB
assets/examples/aml/aml_field_01.jpg ADDED

Git LFS Details

  • SHA256: 9f29f9f12f19679ef74c0107fdd187cb77332a3c8bdaae35045a3e21f9d5d83e
  • Pointer size: 130 Bytes
  • Size of remote file: 84.5 kB
assets/examples/aml/aml_field_02.jpg ADDED

Git LFS Details

  • SHA256: a8a6bd32c0a1a1a383f835953bb962a7e90f762ba8e71bae105f8a5e4509abda
  • Pointer size: 130 Bytes
  • Size of remote file: 90.3 kB
assets/examples/aml/aml_field_03.jpg ADDED

Git LFS Details

  • SHA256: 6e271d1184f84c170b19cd637c0670008fd91048405305a7b0ee495f05e74a45
  • Pointer size: 130 Bytes
  • Size of remote file: 89.2 kB
assets/examples/aml/aml_field_04.jpg ADDED

Git LFS Details

  • SHA256: f757a8c78035e3fbacc2ebc8ee9aef95823211930d1b788a9a8954b8d7873ef5
  • Pointer size: 130 Bytes
  • Size of remote file: 88.4 kB
assets/examples/aml/aml_field_05.jpg ADDED

Git LFS Details

  • SHA256: f3f00e8de472a6682df729e193df3cf857c34aa782b3d5a1324e4550c3cc226f
  • Pointer size: 130 Bytes
  • Size of remote file: 79.9 kB
assets/examples/aml/aml_field_06.jpg ADDED

Git LFS Details

  • SHA256: 6c897b3ac37cb233f18c07f92bdea63351afec85028e4a733fe69435ecd44443
  • Pointer size: 130 Bytes
  • Size of remote file: 86.9 kB
assets/examples/aml/aml_field_07.jpg ADDED

Git LFS Details

  • SHA256: 268b721398b1fead34bd2cd8ba6cd20d40196a2345f7e53a6cf3fb5be6053a40
  • Pointer size: 130 Bytes
  • Size of remote file: 79.2 kB
assets/examples/aml/aml_field_08.jpg ADDED

Git LFS Details

  • SHA256: d88b99626e8b786f441dc0928399ff69888b280ca84c51c77520a41a10f64e43
  • Pointer size: 130 Bytes
  • Size of remote file: 76.7 kB
assets/examples/aml/aml_field_09.jpg ADDED

Git LFS Details

  • SHA256: 924109d20b83d43b6a96e038810850a8f10768429d24ca0ef7b07eae64f593c6
  • Pointer size: 130 Bytes
  • Size of remote file: 75.2 kB
assets/examples/aml/aml_field_10.jpg ADDED

Git LFS Details

  • SHA256: 85dc947c260e732807ff24e199c6306162f53c773d48a48869a388fd4f3d91df
  • Pointer size: 130 Bytes
  • Size of remote file: 81.4 kB
assets/examples/cll/cll_field_01.jpg ADDED

Git LFS Details

  • SHA256: 760d6c02efcb2a6e7c88525a1a6fbf60cae840dbceb478c446c777c53d19be0a
  • Pointer size: 130 Bytes
  • Size of remote file: 95.1 kB
assets/examples/cll/cll_field_02.jpg ADDED

Git LFS Details

  • SHA256: 76eb2cd1c12023d67d4ca7696af083e0b82e969f55ef7d190cc1e7b262358ecd
  • Pointer size: 130 Bytes
  • Size of remote file: 89.4 kB
assets/examples/cll/cll_field_03.jpg ADDED

Git LFS Details

  • SHA256: 5938686b8a95d0cd4cb1b648032d293198b0d77e232183d5a242382dcf7b668a
  • Pointer size: 130 Bytes
  • Size of remote file: 89.9 kB
assets/examples/cll/cll_field_04.jpg ADDED

Git LFS Details

  • SHA256: 2a36a067167a071a95a549337d9af14d6f96b1f838d8b8dd0997c3c5e397ec40
  • Pointer size: 130 Bytes
  • Size of remote file: 85.5 kB
assets/examples/cll/cll_field_05.jpg ADDED

Git LFS Details

  • SHA256: 71453668128b8f660c9904b5fd89ddfa3c62772d8282cffaf688619bed8b03f6
  • Pointer size: 130 Bytes
  • Size of remote file: 77.1 kB
assets/examples/cll/cll_field_06.jpg ADDED

Git LFS Details

  • SHA256: 0bd364a088bb14134145edf90d9342fb5f010e353a9cda10d41d276596b70405
  • Pointer size: 130 Bytes
  • Size of remote file: 68.4 kB
assets/examples/cll/cll_field_07.jpg ADDED

Git LFS Details

  • SHA256: 6086f6b230f76642bb6ad7c43712673600603559fc34e6d1a3b9a626c0e669d2
  • Pointer size: 130 Bytes
  • Size of remote file: 66.5 kB
assets/examples/cll/cll_field_08.jpg ADDED

Git LFS Details

  • SHA256: d4945e68bf56f3d2c47684456a74e63f1f8b16d6d8efe157f0314d20e2ae4e3e
  • Pointer size: 130 Bytes
  • Size of remote file: 63.4 kB
assets/examples/cll/cll_field_09.jpg ADDED

Git LFS Details

  • SHA256: bc965af96ba16603df8c9454e21fd010552efe8dd38366fe72fd4fbb7d1f2354
  • Pointer size: 130 Bytes
  • Size of remote file: 67 kB
assets/examples/cll/cll_field_10.jpg ADDED

Git LFS Details

  • SHA256: 0d2e505ccb152d04ed0664ee030b7cc42bd2356cb09d3d04dca7fe2d70b16634
  • Pointer size: 130 Bytes
  • Size of remote file: 77.4 kB
assets/examples/cml/cml_field_01.jpg ADDED

Git LFS Details

  • SHA256: 9696fd2341fe812b63c3bdb6bdccfb080538572541beef2386b01570b83b37a9
  • Pointer size: 131 Bytes
  • Size of remote file: 103 kB
assets/examples/cml/cml_field_02.jpg ADDED

Git LFS Details

  • SHA256: 43131bdbbd399b6b0e0c3f6ee2a9eaa13ba1090a3f9eaedaa2c1328a84434288
  • Pointer size: 131 Bytes
  • Size of remote file: 109 kB
assets/examples/cml/cml_field_03.jpg ADDED

Git LFS Details

  • SHA256: 472d3f7678bd7537d529207668a226f77881c890037b61595823f1d7df012c98
  • Pointer size: 130 Bytes
  • Size of remote file: 93.6 kB
assets/examples/cml/cml_field_04.jpg ADDED

Git LFS Details

  • SHA256: dc29836516d618526caf83ab33fe2cfbaa4de0161ac94ef7511253b555b09bf2
  • Pointer size: 130 Bytes
  • Size of remote file: 94.2 kB
assets/examples/cml/cml_field_05.jpg ADDED

Git LFS Details

  • SHA256: 332f4dbf534f3b476b6dd3132201bb97c17335768c9d320545a415a25d4324c6
  • Pointer size: 131 Bytes
  • Size of remote file: 102 kB
assets/examples/cml/cml_field_06.jpg ADDED

Git LFS Details

  • SHA256: 40181f891e8051179dcd185aa1b21b72bb030311df6e49e3b10b457d035d85ff
  • Pointer size: 130 Bytes
  • Size of remote file: 96.8 kB
assets/examples/cml/cml_field_07.jpg ADDED

Git LFS Details

  • SHA256: fd798ef3f57c66d1444396f912a3f015ebfca752f6e365b68ec1e2e62bafcb3b
  • Pointer size: 131 Bytes
  • Size of remote file: 107 kB
assets/examples/cml/cml_field_08.jpg ADDED

Git LFS Details

  • SHA256: 6c90b261765a0eecedf5154fc4546f8d395ad827f1058141c10dc6d13ed0bb7a
  • Pointer size: 131 Bytes
  • Size of remote file: 102 kB
assets/examples/cml/cml_field_09.jpg ADDED

Git LFS Details

  • SHA256: a50b9b237acf61156e4e06582d1f26edfeb4edc0dccbdc3bd6435f99b717f5f1
  • Pointer size: 130 Bytes
  • Size of remote file: 82.7 kB
assets/examples/cml/cml_field_10.jpg ADDED

Git LFS Details

  • SHA256: 9c8c7ddfd39e806a6192bc9870b3758e63ec5e56fc5b6fec19be838732833dea
  • Pointer size: 130 Bytes
  • Size of remote file: 88.6 kB
assets/examples/normal/normal_field_01.jpg ADDED

Git LFS Details

  • SHA256: a190df06501596ff15c49cd4e19fee3c1032c8756e01fb8ac8c77a3b5cb7e5ae
  • Pointer size: 130 Bytes
  • Size of remote file: 71.6 kB
assets/examples/normal/normal_field_02.jpg ADDED

Git LFS Details

  • SHA256: a14bf7f0e6c24aaea2392d157fd5836a63a77d4d933b14ea65d77c75211e5fc6
  • Pointer size: 130 Bytes
  • Size of remote file: 79.8 kB
assets/examples/normal/normal_field_03.jpg ADDED

Git LFS Details

  • SHA256: 421fce0980be2fd1a1a66e9622c1d36b3c125c2ae84fba9f292161ef3bd3242b
  • Pointer size: 130 Bytes
  • Size of remote file: 85.8 kB
assets/examples/normal/normal_field_04.jpg ADDED

Git LFS Details

  • SHA256: 2dc8de691b1dd40f0bf0b4816d4aceb238fd0f1e78b8548918bf979b2b86fbff
  • Pointer size: 130 Bytes
  • Size of remote file: 79.1 kB
assets/examples/normal/normal_field_05.jpg ADDED

Git LFS Details

  • SHA256: 93f3985cc4ad20ebc679ac8a956d5bbff0f350f8d7f760715c2cd962b301b85f
  • Pointer size: 130 Bytes
  • Size of remote file: 78.7 kB
assets/examples/normal/normal_field_06.jpg ADDED

Git LFS Details

  • SHA256: 71a0915f0795ef021fc161de514a98ee734fe337ee0178fc576f32e9ea015a66
  • Pointer size: 130 Bytes
  • Size of remote file: 67.5 kB
assets/examples/normal/normal_field_07.jpg ADDED

Git LFS Details

  • SHA256: 9f7aaa016b9677f903cffe08c37bdf0c8490efa73721e2f32258b47a5b30972e
  • Pointer size: 130 Bytes
  • Size of remote file: 80.9 kB
assets/examples/normal/normal_field_08.jpg ADDED

Git LFS Details

  • SHA256: 224f1f6d2d23cffddbb7a1802074af4f72657d7354f40568c8afd9a4ee83e946
  • Pointer size: 130 Bytes
  • Size of remote file: 93.2 kB
assets/examples/normal/normal_field_09.jpg ADDED

Git LFS Details

  • SHA256: 756204376b1f12600c6bfcc66a6faa327834e72c47f99bd11d678ab88301e7ee
  • Pointer size: 130 Bytes
  • Size of remote file: 96.6 kB
assets/examples/normal/normal_field_10.jpg ADDED

Git LFS Details

  • SHA256: 140c8b5f6c9eaad5f085b4f43cd16140816bc81c3b3311b64eab5125435ea4e9
  • Pointer size: 130 Bytes
  • Size of remote file: 93 kB
assets/logo-mark.svg ADDED
leukoai/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ """LeukoAI: leukemia prediction from peripheral blood smear images. Research use only."""
2
+ __version__ = "1.1.0.dev0"
3
+
4
+ from .models import DX1, DX2, DX3, load_models # noqa: F401
5
+ from .pipeline import analyse_images, analyse_paths # noqa: F401
leukoai/experimental.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """EXPERIMENTAL case rules. Research comparison only; NOT used by the web app.
2
+
3
+ Each rule changes exactly ONE step of the published rule (acute vs chronic),
4
+ so differences in results can be attributed to that step alone:
5
+
6
+ * Leukemia vs no leukemia: unchanged (more than T images classed leukemic by Model 1).
7
+ * Subtype: unchanged (Model 3 image votes within ALL/AML or CLL/CML; ties -> ALL/CLL).
8
+ * Acute vs chronic: replaced, as below.
9
+
10
+ Both rules are parameter-free and were specified on 29 Oct 2026, before any
11
+ further patient data were analysed, so they cannot have been tuned to the
12
+ validation cases. Motivation: in public LeukoAI images, 11 AML fields classed
13
+ chronic by Model 2 turned an AML folder into "CLL" under the published
14
+ "2 or more chronic images" step (docs/FINDINGS_v1.md).
15
+
16
+ EXP_MAJORITY
17
+ Chronic if more images are classed chronic than acute by Model 2
18
+ (all images counted, as in the published rule). Ties -> acute, because
19
+ missing acute leukemia is the more dangerous error.
20
+
21
+ EXP_MEANPROB
22
+ Among images classed leukemic by Model 1, average Model 2's acute and
23
+ chronic probabilities. Chronic if mean P(chronic) > mean P(acute);
24
+ ties -> acute. Uses confidence rather than votes.
25
+ """
26
+ from __future__ import annotations
27
+
28
+ from .models import DX3
29
+ from .rules import ImageResult
30
+
31
+ RULES = ("exp_majority", "exp_meanprob")
32
+
33
+
34
+ def _leukemic(images, threshold):
35
+ return sum(1 for im in images if im.k1 == 1) > threshold
36
+
37
+
38
+ def _subtype(images, chronic: bool) -> str:
39
+ votes = [sum(1 for im in images if im.k3 == k) for k in range(5)]
40
+ sub = votes[2:4] if chronic else votes[:2]
41
+ return DX3[sub.index(max(sub)) + (2 if chronic else 0)]
42
+
43
+
44
+ def _finish(images, chronic: bool):
45
+ return ("CHRONIC LEUKEMIA" if chronic else "ACUTE LEUKEMIA"), _subtype(images, chronic)
46
+
47
+
48
+ def exp_majority(images: list[ImageResult], threshold: int = 3):
49
+ if not _leukemic(images, threshold):
50
+ return "NO LEUKEMIA", "NO LEUKEMIA"
51
+ acute = sum(1 for im in images if im.k2 == 0)
52
+ chronic = sum(1 for im in images if im.k2 == 1)
53
+ return _finish(images, chronic > acute)
54
+
55
+
56
+ def exp_meanprob(images: list[ImageResult], threshold: int = 3):
57
+ if not _leukemic(images, threshold):
58
+ return "NO LEUKEMIA", "NO LEUKEMIA"
59
+ leuk = [im for im in images if im.k1 == 1]
60
+ pa = sum(im.p2[0] for im in leuk) / len(leuk)
61
+ pc = sum(im.p2[1] for im in leuk) / len(leuk)
62
+ return _finish(images, pc > pa)
63
+
64
+
65
+ def run_all(images: list[ImageResult], threshold: int = 3) -> dict:
66
+ return {"exp_majority": exp_majority(images, threshold), "exp_meanprob": exp_meanprob(images, threshold)}