KPrashanth commited on
Commit
344f0b4
·
verified ·
1 Parent(s): 8bf00c4

Upload 11 files

Browse files
.gitattributes CHANGED
@@ -1,35 +1,3 @@
1
- *.7z filter=lfs diff=lfs merge=lfs -text
2
- *.arrow filter=lfs diff=lfs merge=lfs -text
3
- *.bin filter=lfs diff=lfs merge=lfs -text
4
- *.bz2 filter=lfs diff=lfs merge=lfs -text
5
- *.ckpt filter=lfs diff=lfs merge=lfs -text
6
- *.ftz filter=lfs diff=lfs merge=lfs -text
7
- *.gz filter=lfs diff=lfs merge=lfs -text
8
- *.h5 filter=lfs diff=lfs merge=lfs -text
9
- *.joblib filter=lfs diff=lfs merge=lfs -text
10
- *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
- *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
- *.model filter=lfs diff=lfs merge=lfs -text
13
- *.msgpack filter=lfs diff=lfs merge=lfs -text
14
- *.npy filter=lfs diff=lfs merge=lfs -text
15
- *.npz filter=lfs diff=lfs merge=lfs -text
16
- *.onnx filter=lfs diff=lfs merge=lfs -text
17
- *.ot filter=lfs diff=lfs merge=lfs -text
18
- *.parquet filter=lfs diff=lfs merge=lfs -text
19
- *.pb filter=lfs diff=lfs merge=lfs -text
20
- *.pickle filter=lfs diff=lfs merge=lfs -text
21
- *.pkl filter=lfs diff=lfs merge=lfs -text
22
  *.pt filter=lfs diff=lfs merge=lfs -text
23
  *.pth filter=lfs diff=lfs merge=lfs -text
24
- *.rar filter=lfs diff=lfs merge=lfs -text
25
- *.safetensors filter=lfs diff=lfs merge=lfs -text
26
- saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
- *.tar.* filter=lfs diff=lfs merge=lfs -text
28
- *.tar filter=lfs diff=lfs merge=lfs -text
29
- *.tflite filter=lfs diff=lfs merge=lfs -text
30
- *.tgz filter=lfs diff=lfs merge=lfs -text
31
- *.wasm filter=lfs diff=lfs merge=lfs -text
32
- *.xz filter=lfs diff=lfs merge=lfs -text
33
- *.zip filter=lfs diff=lfs merge=lfs -text
34
- *.zst filter=lfs diff=lfs merge=lfs -text
35
- *tfevents* filter=lfs diff=lfs merge=lfs -text
 
1
+ wbc_classifier/best_model_checkpoint.pth filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  *.pt filter=lfs diff=lfs merge=lfs -text
3
  *.pth filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
.gitignore ADDED
@@ -0,0 +1,207 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[codz]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py.cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # Sphinx documentation
72
+ docs/_build/
73
+
74
+ # PyBuilder
75
+ .pybuilder/
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ # For a library or package, you might want to ignore these files since the code is
87
+ # intended to run in multiple environments; otherwise, check them in:
88
+ # .python-version
89
+
90
+ # pipenv
91
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
+ # install all needed dependencies.
95
+ #Pipfile.lock
96
+
97
+ # UV
98
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
99
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
100
+ # commonly ignored for libraries.
101
+ #uv.lock
102
+
103
+ # poetry
104
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
105
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
106
+ # commonly ignored for libraries.
107
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
108
+ #poetry.lock
109
+ #poetry.toml
110
+
111
+ # pdm
112
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
113
+ # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
114
+ # https://pdm-project.org/en/latest/usage/project/#working-with-version-control
115
+ #pdm.lock
116
+ #pdm.toml
117
+ .pdm-python
118
+ .pdm-build/
119
+
120
+ # pixi
121
+ # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
122
+ #pixi.lock
123
+ # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
124
+ # in the .venv directory. It is recommended not to include this directory in version control.
125
+ .pixi
126
+
127
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
128
+ __pypackages__/
129
+
130
+ # Celery stuff
131
+ celerybeat-schedule
132
+ celerybeat.pid
133
+
134
+ # SageMath parsed files
135
+ *.sage.py
136
+
137
+ # Environments
138
+ .env
139
+ .envrc
140
+ .venv
141
+ env/
142
+ venv/
143
+ ENV/
144
+ env.bak/
145
+ venv.bak/
146
+
147
+ # Spyder project settings
148
+ .spyderproject
149
+ .spyproject
150
+
151
+ # Rope project settings
152
+ .ropeproject
153
+
154
+ # mkdocs documentation
155
+ /site
156
+
157
+ # mypy
158
+ .mypy_cache/
159
+ .dmypy.json
160
+ dmypy.json
161
+
162
+ # Pyre type checker
163
+ .pyre/
164
+
165
+ # pytype static type analyzer
166
+ .pytype/
167
+
168
+ # Cython debug symbols
169
+ cython_debug/
170
+
171
+ # PyCharm
172
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
173
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
174
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
175
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
176
+ #.idea/
177
+
178
+ # Abstra
179
+ # Abstra is an AI-powered process automation framework.
180
+ # Ignore directories containing user credentials, local state, and settings.
181
+ # Learn more at https://abstra.io/docs
182
+ .abstra/
183
+
184
+ # Visual Studio Code
185
+ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
186
+ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
187
+ # and can be added to the global gitignore or merged into this file. However, if you prefer,
188
+ # you could uncomment the following to ignore the entire vscode folder
189
+ # .vscode/
190
+
191
+ # Ruff stuff:
192
+ .ruff_cache/
193
+
194
+ # PyPI configuration file
195
+ .pypirc
196
+
197
+ # Cursor
198
+ # Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
199
+ # exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
200
+ # refer to https://docs.cursor.com/context/ignore-files
201
+ .cursorignore
202
+ .cursorindexingignore
203
+
204
+ # Marimo
205
+ marimo/_static/
206
+ marimo/_lsp/
207
+ __marimo__/
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Katakam Prashanth
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md CHANGED
@@ -1,13 +1 @@
1
- ---
2
- title: SmartCBC
3
- emoji: 🔥
4
- colorFrom: gray
5
- colorTo: yellow
6
- sdk: gradio
7
- sdk_version: 6.0.0
8
- app_file: app.py
9
- pinned: false
10
- license: mit
11
- ---
12
-
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
1
+ # SmartCBC-Multimodal-Pipeline
 
 
 
 
 
 
 
 
 
 
 
 
__init__.py ADDED
File without changes
app.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app.py
2
+
3
+ import gradio as gr
4
+ from PIL import Image
5
+
6
+ from pipeline import SmartCBC
7
+
8
+ # -------------------------------------------------
9
+ # Initialize pipeline ONCE (cached in Spaces)
10
+ # -------------------------------------------------
11
+ cbc = SmartCBC() # loads YOLO + classifier once
12
+
13
+
14
+ # -------------------------------------------------
15
+ # Core Gradio wrapper
16
+ # -------------------------------------------------
17
+ def analyze_images(images, age, gender, output_mode):
18
+ """
19
+ Wrapper for SmartCBC.analyze() or SmartCBC.analyze_batch()
20
+ depending on whether 1 or multiple images are uploaded.
21
+ """
22
+ if images is None or len(images) == 0:
23
+ return "Please upload at least one image.", None
24
+
25
+ # Gradio Gallery sometimes returns list of tuples: (PIL.Image, metadata)
26
+ def to_pil_list(img_list):
27
+ pil_list = []
28
+ for item in img_list:
29
+ if isinstance(item, tuple):
30
+ # assume first element is the PIL image
31
+ pil = item[0]
32
+ else:
33
+ pil = item
34
+ pil_list.append(pil)
35
+ return pil_list
36
+
37
+ pil_images = to_pil_list(images)
38
+
39
+ # If 1 image → single-FOV path
40
+ if len(pil_images) == 1:
41
+ result = cbc.analyze(
42
+ image=pil_images[0],
43
+ age=age,
44
+ gender=gender
45
+ )
46
+ else:
47
+ # Multiple images → multi-FOV aggregation
48
+ result = cbc.analyze(
49
+ image=pil_images, # list → analyze_batch under the hood
50
+ age=age,
51
+ gender=gender
52
+ )
53
+
54
+ if output_mode == "Text Report":
55
+ return result["report_text"], None
56
+ else:
57
+ return None, result
58
+
59
+
60
+ # -------------------------------------------------
61
+ # Gradio UI Layout
62
+ # -------------------------------------------------
63
+ with gr.Blocks(title="SmartCBC - Multimodal Blood Analysis") as demo:
64
+
65
+ gr.Markdown("""
66
+ # 🩸 SmartCBC — Multimodal AI Blood Smear Analysis
67
+ Upload **one or multiple** peripheral smear FOV images and get:
68
+ - RBC / WBC / Platelet counts
69
+ - WBC subtype classification
70
+ - Aggregated multi-FOV differential
71
+ - Age-specific reference comparisons
72
+ - Clinical insights (non-diagnostic)
73
+ """)
74
+
75
+ with gr.Row():
76
+ img_in = gr.Gallery(
77
+ label="Upload 1 or Multiple Blood Smear Images (FOVs)",
78
+ columns=3,
79
+ height="auto",
80
+ allow_preview=True,
81
+ type="pil"
82
+ )
83
+
84
+ with gr.Column():
85
+ age_in = gr.Number(label="Age (years)", value=30)
86
+ gender_in = gr.Dropdown(
87
+ ["M", "F", ""],
88
+ label="Gender (optional)",
89
+ value=""
90
+ )
91
+ output_mode = gr.Radio(
92
+ ["Text Report", "Structured JSON"],
93
+ value="Text Report",
94
+ label="Output Format"
95
+ )
96
+ btn = gr.Button("Analyze")
97
+
98
+ # OUTPUT AREAS
99
+ txt_out = gr.Textbox(
100
+ label="Report (Human Readable)",
101
+ visible=True,
102
+ lines=30,
103
+ interactive=False
104
+ )
105
+
106
+ json_out = gr.JSON(
107
+ label="Structured Output (JSON)",
108
+ visible=False
109
+ )
110
+
111
+ # Toggle visibility
112
+ def toggle_output(mode):
113
+ return (
114
+ gr.update(visible=(mode == "Text Report")),
115
+ gr.update(visible=(mode == "Structured JSON"))
116
+ )
117
+
118
+ output_mode.change(toggle_output, [output_mode], [txt_out, json_out])
119
+
120
+ # Button Binding
121
+ btn.click(
122
+ analyze_images,
123
+ inputs=[img_in, age_in, gender_in, output_mode],
124
+ outputs=[txt_out, json_out]
125
+ )
126
+
127
+
128
+ # -------------------------------------------------
129
+ # HF Spaces entrypoint
130
+ # -------------------------------------------------
131
+ if __name__ == "__main__":
132
+ demo.launch()
pipeline.py ADDED
@@ -0,0 +1,292 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # pipeline.py
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ from typing import Any, Dict, Optional, Sequence, List
9
+
10
+ import numpy as np
11
+ import torch
12
+ from PIL import Image
13
+
14
+ from utils.detector import load_detector, run_detector
15
+ from utils.classifier import load_wbc_classifier, classify_wbc_crop
16
+ from utils.analysis import CLASS_NAMES, map_age_to_group, pick_gender_for_group
17
+ from utils.report import build_api_response
18
+
19
+ # -------------------------------------------------
20
+ # PATHS & DEVICE
21
+ # -------------------------------------------------
22
+ REPO_ROOT = Path(__file__).resolve().parent
23
+
24
+ DETECTOR_WEIGHTS = REPO_ROOT / "yolov8_detector" / "best.pt"
25
+ CLASSIFIER_WEIGHTS = REPO_ROOT / "wbc_classifier" / "best_model_checkpoint.pth"
26
+ REF_CSV = REPO_ROOT / "data" / "WBC differential references.csv"
27
+
28
+ DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
29
+
30
+
31
+ class SmartCBC:
32
+ """
33
+ Main SmartCBC pipeline orchestrator.
34
+
35
+ Usage (single FOV):
36
+
37
+ from pipeline import SmartCBC
38
+ cbc = SmartCBC()
39
+ result = cbc.analyze(image, age=32, gender="M")
40
+
41
+ Usage (multiple FOVs):
42
+
43
+ result = cbc.analyze_batch([img1, img2, img3], age=32, gender="M")
44
+
45
+ You can also pass a list directly to `analyze()` and it will auto-route:
46
+
47
+ result = cbc.analyze([img1, img2, img3], age=32, gender="M")
48
+
49
+ `result` is a dict ready for API / UI usage:
50
+ - patient_id
51
+ - timestamp
52
+ - fovs_analyzed
53
+ - coarse_counts (RBC/WBC/Platelet)
54
+ - wbc_subtypes (raw counts per subtype)
55
+ - wbc_percentages (percent per subtype)
56
+ - report_text (plain text report)
57
+ - calibration (placeholders for now)
58
+ """
59
+
60
+ def __init__(
61
+ self,
62
+ detector_weights: Optional[str | Path] = None,
63
+ classifier_weights: Optional[str | Path] = None,
64
+ ref_csv: Optional[str | Path] = None,
65
+ conf_thres: float = 0.25,
66
+ imgsz: int = 512,
67
+ ) -> None:
68
+ self.detector_weights = str(detector_weights or DETECTOR_WEIGHTS)
69
+ self.classifier_weights = str(classifier_weights or CLASSIFIER_WEIGHTS)
70
+ self.ref_csv = str(ref_csv or REF_CSV)
71
+
72
+ self.conf_thres = conf_thres
73
+ self.imgsz = imgsz
74
+ self.device = DEVICE
75
+
76
+ # Load models once
77
+ self.detector = load_detector(self.detector_weights)
78
+ self.classifier = load_wbc_classifier(self.classifier_weights)
79
+
80
+ # -------------------------------------------------
81
+ # PUBLIC ENTRYPOINT (single OR multi)
82
+ # -------------------------------------------------
83
+ def analyze(
84
+ self,
85
+ image: Any | Sequence[Any],
86
+ age: Optional[float] = None,
87
+ gender: Optional[str] = None,
88
+ ) -> Dict[str, Any]:
89
+ """
90
+ Run SmartCBC analysis.
91
+
92
+ If `image` is:
93
+ - a single image (PIL / np.ndarray / path) -> analyze one FOV
94
+ - a list/tuple of images -> aggregate over multiple FOVs
95
+ """
96
+
97
+ # If multiple FOVs are provided, delegate to analyze_batch()
98
+ if isinstance(image, (list, tuple)):
99
+ return self.analyze_batch(list(image), age=age, gender=gender)
100
+
101
+ # Single-image path (current behavior)
102
+ pil_img = self._ensure_pil(image)
103
+
104
+ age_years, age_group, gender = self._resolve_age_gender(age, gender)
105
+
106
+ coarse_counts, subtype_counts = self._run_models_on_image(pil_img)
107
+
108
+ ai_result = {
109
+ "patient_id": f"PAT-{uuid.uuid4().hex[:8].upper()}",
110
+ "timestamp": datetime.now().isoformat(timespec="seconds"),
111
+ "fovs_analyzed": 1,
112
+ "coarse_counts": coarse_counts,
113
+ "wbc_subtypes": subtype_counts,
114
+ "calibration": {
115
+ "fov_area_mm2": None,
116
+ "calibration_constant": None,
117
+ },
118
+ }
119
+
120
+ response = build_api_response(
121
+ ai_result=ai_result,
122
+ age_group=age_group,
123
+ gender=gender,
124
+ reference_csv=self.ref_csv,
125
+ overlay_image=None,
126
+ )
127
+
128
+ response["age_years"] = age_years
129
+ response["age_group"] = age_group
130
+ response["gender"] = gender
131
+
132
+ return response
133
+
134
+ # -------------------------------------------------
135
+ # NEW: MULTI-FOV ANALYSIS
136
+ # -------------------------------------------------
137
+ def analyze_batch(
138
+ self,
139
+ images: Sequence[Any],
140
+ age: Optional[float] = None,
141
+ gender: Optional[str] = None,
142
+ ) -> Dict[str, Any]:
143
+ """
144
+ Run SmartCBC analysis over MULTIPLE FOV images.
145
+
146
+ Parameters
147
+ ----------
148
+ images : list/tuple of PIL / np.ndarray / path
149
+ age : float (years), optional
150
+ gender : "M" | "F", optional
151
+
152
+ Returns
153
+ -------
154
+ Aggregated result dict:
155
+ - fovs_analyzed = len(images)
156
+ - coarse_counts (sum over all FOVs)
157
+ - wbc_subtypes (sum over all FOVs)
158
+ - wbc_percentages, report_text, etc.
159
+ """
160
+
161
+ if not images:
162
+ raise ValueError("analyze_batch() received an empty images list.")
163
+
164
+ age_years, age_group, gender = self._resolve_age_gender(age, gender)
165
+
166
+ # Initialize aggregate counts
167
+ agg_coarse: Dict[str, int] = {"WBC": 0, "RBC": 0, "Platelet": 0}
168
+ agg_subtypes: Dict[str, int] = {name: 0 for name in CLASS_NAMES}
169
+
170
+ fov_count = 0
171
+
172
+ for img in images:
173
+ pil_img = self._ensure_pil(img)
174
+ coarse_counts, subtype_counts = self._run_models_on_image(pil_img)
175
+
176
+ # Aggregate coarse counts
177
+ for k, v in coarse_counts.items():
178
+ agg_coarse[k] = agg_coarse.get(k, 0) + v
179
+
180
+ # Aggregate subtype counts
181
+ for k, v in subtype_counts.items():
182
+ agg_subtypes[k] = agg_subtypes.get(k, 0) + v
183
+
184
+ fov_count += 1
185
+
186
+ ai_result = {
187
+ "patient_id": f"PAT-{uuid.uuid4().hex[:8].upper()}",
188
+ "timestamp": datetime.now().isoformat(timespec="seconds"),
189
+ "fovs_analyzed": fov_count,
190
+ "coarse_counts": agg_coarse,
191
+ "wbc_subtypes": agg_subtypes,
192
+ "calibration": {
193
+ "fov_area_mm2": None,
194
+ "calibration_constant": None,
195
+ },
196
+ }
197
+
198
+ response = build_api_response(
199
+ ai_result=ai_result,
200
+ age_group=age_group,
201
+ gender=gender,
202
+ reference_csv=self.ref_csv,
203
+ overlay_image=None,
204
+ )
205
+
206
+ response["age_years"] = age_years
207
+ response["age_group"] = age_group
208
+ response["gender"] = gender
209
+
210
+ return response
211
+
212
+ # -------------------------------------------------
213
+ # INTERNAL HELPERS
214
+ # -------------------------------------------------
215
+ def _resolve_age_gender(
216
+ self,
217
+ age: Optional[float],
218
+ gender: Optional[str],
219
+ ) -> tuple[float, str, Optional[str]]:
220
+ """
221
+ Compute age_years, age_group, gender with defaults and CSV-based inference.
222
+ """
223
+ age_years = float(age) if age is not None else 30.0
224
+ age_group = map_age_to_group(age_years)
225
+
226
+ if gender is None or str(gender).strip() == "":
227
+ gender = pick_gender_for_group(age_group, csv_path=self.ref_csv)
228
+ gender = None if gender is None else str(gender).upper()
229
+
230
+ return age_years, age_group, gender
231
+
232
+ def _ensure_pil(self, image: Any) -> Image.Image:
233
+ """
234
+ Convert various input types to a PIL.Image in RGB mode.
235
+ """
236
+ if isinstance(image, Image.Image):
237
+ return image.convert("RGB")
238
+ if isinstance(image, np.ndarray):
239
+ if image.ndim == 2:
240
+ image = np.stack([image] * 3, axis=-1)
241
+ return Image.fromarray(image).convert("RGB")
242
+ if isinstance(image, (str, Path)):
243
+ return Image.open(image).convert("RGB")
244
+ raise TypeError(f"Unsupported image type: {type(image)}")
245
+
246
+ def _run_models_on_image(
247
+ self,
248
+ img: Image.Image,
249
+ ) -> tuple[Dict[str, int], Dict[str, int]]:
250
+ """
251
+ Run YOLO detector + WBC classifier on a single image.
252
+
253
+ Returns
254
+ -------
255
+ coarse_counts : {"WBC": int, "RBC": int, "Platelet": int}
256
+ subtype_counts: {subtype_name: int}
257
+ """
258
+ coarse_counts: Dict[str, int] = {"WBC": 0, "RBC": 0, "Platelet": 0}
259
+ subtype_counts: Dict[str, int] = {name: 0 for name in CLASS_NAMES}
260
+
261
+ detections = run_detector(
262
+ model=self.detector,
263
+ image=img,
264
+ imgsz=self.imgsz,
265
+ conf_thres=self.conf_thres,
266
+ )
267
+
268
+ w, h = img.size
269
+
270
+ for det in detections:
271
+ x1, y1, x2, y2 = det["box"]
272
+ label = det["label"] # "RBC", "WBC", "Platelet"
273
+
274
+ x1 = max(int(x1), 0)
275
+ y1 = max(int(y1), 0)
276
+ x2 = min(int(x2), w)
277
+ y2 = min(int(y2), h)
278
+
279
+ if label in coarse_counts:
280
+ coarse_counts[label] += 1
281
+ else:
282
+ coarse_counts[label] = 1
283
+
284
+ if label == "WBC" and x2 > x1 and y2 > y1:
285
+ crop = img.crop((x1, y1, x2, y2))
286
+ subtype = classify_wbc_crop(self.classifier, crop)
287
+ if subtype in subtype_counts:
288
+ subtype_counts[subtype] += 1
289
+ else:
290
+ subtype_counts[subtype] = 1
291
+
292
+ return coarse_counts, subtype_counts
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ torch
2
+ torchvision
3
+ ultralytics
4
+ gradio
5
+ pandas
6
+ Pillow
test_pipeline.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # test_pipeline.py
2
+
3
+ from pathlib import Path
4
+ from PIL import Image
5
+
6
+ from pipeline import SmartCBC
7
+
8
+ def main():
9
+ # 1) Initialize pipeline (loads YOLO + classifier)
10
+ cbc = SmartCBC()
11
+
12
+ # 2) Pick a sample FOV image (from your TXL-PBC test set)
13
+ test_dir = Path("/home/enma/Projects/blood_analyzer/TXL-PBC_Dataset/TXL-PBC/images/test")
14
+ image_paths = list(test_dir.glob("*.*"))
15
+
16
+ if not image_paths:
17
+ print(f"No images found in {test_dir}")
18
+ return
19
+
20
+ sample_path = image_paths[0]
21
+ print(f"Using sample image: {sample_path}")
22
+
23
+ img = Image.open(sample_path).convert("RGB")
24
+
25
+ # 3) Run analysis
26
+ result = cbc.analyze(
27
+ image=img,
28
+ age=32, # you can change this
29
+ gender="M", # or leave None / ""
30
+ )
31
+
32
+ # 4) Print key outputs
33
+ print("\n=== Coarse Counts ===")
34
+ print(result.get("coarse_counts"))
35
+
36
+ print("\n=== WBC Subtypes ===")
37
+ print(result.get("wbc_subtypes"))
38
+
39
+ print("\n=== WBC Percentages ===")
40
+ print(result.get("wbc_percentages"))
41
+
42
+ print("\n=== Report Text (first 40 lines) ===")
43
+ report_lines = result.get("report_text", "").splitlines()
44
+ for line in report_lines[:40]:
45
+ print(line)
46
+
47
+ if __name__ == "__main__":
48
+ main()
wbc_classifier/best_model_checkpoint.pth ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f987d514b82516f3a0b974a01ddd07fb98b0cc4049020a96dd5e3df1fabf0a82
3
+ size 134
yolov8_detector/best.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:fd5a31cf18dbfd11f35248cfde75a8dd854fff6ca6549c6de035ce295f64944d
3
+ size 5609270