larxius commited on
Commit
d288282
·
1 Parent(s): 7361fab

feat(lab): Lab Report Analysis - OCR + Groq AI

Browse files
.env.example CHANGED
@@ -34,5 +34,8 @@ MAIL_USERNAME=
34
  MAIL_PASSWORD=
35
  MAIL_DEFAULT_SENDER=
36
 
 
 
 
37
  # Set ALLOW_DROP_TABLES=true ONLY for a deliberate local reset. Never on prod.
38
  ALLOW_DROP_TABLES=false
 
34
  MAIL_PASSWORD=
35
  MAIL_DEFAULT_SENDER=
36
 
37
+ # Lab report AI (Groq API-only, no local model)
38
+ GROQ_API_KEY=
39
+
40
  # Set ALLOW_DROP_TABLES=true ONLY for a deliberate local reset. Never on prod.
41
  ALLOW_DROP_TABLES=false
app.py CHANGED
@@ -562,6 +562,41 @@ def create_app():
562
  except Exception as e:
563
  return jsonify({'success': False, 'error': str(e)}), 500
564
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
565
  @app.route('/api/save_gastro_audit', methods=['POST'])
566
  def save_gastro_audit():
567
  try:
 
562
  except Exception as e:
563
  return jsonify({'success': False, 'error': str(e)}), 500
564
 
565
+ @app.route('/api/extract_lab_report', methods=['POST'])
566
+ def extract_lab_report():
567
+ try:
568
+ if 'file' not in request.files:
569
+ return jsonify({'success': False, 'error': 'No file uploaded.'})
570
+ f = request.files['file']
571
+ if not f.filename:
572
+ return jsonify({'success': False, 'error': 'No file selected.'})
573
+ import uuid
574
+ from werkzeug.utils import secure_filename
575
+ fname = f"{uuid.uuid4().hex}_{secure_filename(f.filename)}"
576
+ fpath = os.path.join(app.config['UPLOAD_FOLDER'], fname)
577
+ f.save(fpath)
578
+ from lab_prediction import process_lab_image
579
+ return jsonify(process_lab_image(fpath))
580
+ except Exception as e:
581
+ return jsonify({'success': False, 'error': str(e)}), 500
582
+
583
+ @app.route('/api/predict_lab_report', methods=['POST'])
584
+ def predict_lab_report():
585
+ try:
586
+ data = request.json or {}
587
+ from lab_prediction import predict_lab_disease, FEATURES
588
+ values = {k: (data.get(k) if data.get(k) != '' else None) for k in FEATURES}
589
+ for k in list(values):
590
+ v = values[k]
591
+ if k != 'Gender' and v is not None:
592
+ try:
593
+ values[k] = float(v)
594
+ except (ValueError, TypeError):
595
+ pass
596
+ return jsonify(predict_lab_disease(values))
597
+ except Exception as e:
598
+ return jsonify({'success': False, 'error': str(e)}), 500
599
+
600
  @app.route('/api/save_gastro_audit', methods=['POST'])
601
  def save_gastro_audit():
602
  try:
lab_prediction.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Lab report disease prediction — Groq API only (no local .pkl model).
2
+
3
+ Pipeline:
4
+ process_lab_image(path) -> OCR (ocr_utils) -> 13-field dict + raw text
5
+ predict_lab_disease(values) -> Groq chat-completions -> disease + confidence
6
+
7
+ Predictable diseases: Anemia, Asthma, Heart Attack, Liver Disease,
8
+ Infection, Diabetics, Cardiovascular Disease, Pancreatitis, Kidney Disease.
9
+ """
10
+ import json
11
+ import os
12
+ import re
13
+
14
+ import requests
15
+
16
+ FEATURES = [
17
+ "Gender", "Age", "Hemoglobin", "RBC", "WBC", "AST", "ALT",
18
+ "Cholesterol", "Spirometry", "Creatinine", "Glucose", "Lipase", "Troponin",
19
+ ]
20
+
21
+ DISEASES = [
22
+ "Anemia", "Asthma", "Heart Attack", "Liver Disease", "Infection",
23
+ "Diabetics", "Cardiovascular Disease", "Pancreatitis", "Kidney Disease",
24
+ ]
25
+
26
+ MODEL_CANDIDATES = [
27
+ "openai/gpt-oss-120b",
28
+ "openai/gpt-oss-20b",
29
+ "qwen/qwen3-32b",
30
+ "llama-3.3-70b-versatile",
31
+ "llama-3.1-8b-instant",
32
+ ]
33
+
34
+ GROQ_URL = "https://api.groq.com/openai/v1/chat/completions"
35
+
36
+
37
+ def _groq_key() -> str:
38
+ return os.environ.get("GROQ_API_KEY", "")
39
+
40
+
41
+ def process_lab_image(image_path: str) -> dict:
42
+ """OCR a lab report image -> {success, extracted_values, raw_text}."""
43
+ from ocr_utils import ocr_image, extract_lab_values
44
+ try:
45
+ raw = ocr_image(image_path)
46
+ except Exception as e:
47
+ return {"success": False, "error": f"OCR failed: {e}"}
48
+ return {
49
+ "success": True,
50
+ "extracted_values": extract_lab_values(raw),
51
+ "raw_text": raw[:2000],
52
+ }
53
+
54
+
55
+ def _prompt(values: dict) -> str:
56
+ lines = "\n".join(f"- {k}: {values.get(k)}" for k in FEATURES)
57
+ return (
58
+ "You are a clinical lab assistant. Given these lab values:\n"
59
+ f"{lines}\n\n"
60
+ f"Possible diseases: {', '.join(DISEASES)}.\n"
61
+ "Reply with ONLY valid JSON, no markdown, no extra text, "
62
+ 'in this shape: {"disease": "<one of the list>", '
63
+ '"confidence": <0-100 number>, '
64
+ '"top3": [{"disease": "<name>", "confidence": <0-100>}]}'
65
+ )
66
+
67
+
68
+ def _parse_reply(text: str) -> dict:
69
+ cleaned = text.strip()
70
+ if cleaned.startswith("```"):
71
+ cleaned = re_sub_fences(cleaned)
72
+ data = json.loads(cleaned)
73
+ disease = str(data.get("disease", "")).strip()
74
+ conf = float(data.get("confidence", 0))
75
+ top3 = data.get("top3") or [{"disease": disease, "confidence": conf}]
76
+ return {"disease": disease, "confidence": round(conf, 1), "top3": top3}
77
+
78
+
79
+ def re_sub_fences(s: str) -> str:
80
+ s = re.sub(r"^```[a-zA-Z]*\n?", "", s)
81
+ s = re.sub(r"\n?```$", "", s)
82
+ return s.strip()
83
+
84
+
85
+ def predict_lab_disease(values: dict) -> dict:
86
+ """Call Groq (model fallback chain) -> disease prediction dict."""
87
+ api_key = _groq_key()
88
+ if not api_key:
89
+ return {"success": False, "error": "GROQ_API_KEY is not set on the server."}
90
+
91
+ missing = [k for k in FEATURES if values.get(k) in (None, "")]
92
+ prompt = _prompt(values)
93
+ headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
94
+ last_err = "unknown error"
95
+
96
+ for model in MODEL_CANDIDATES:
97
+ try:
98
+ r = requests.post(
99
+ GROQ_URL,
100
+ headers=headers,
101
+ json={
102
+ "model": model,
103
+ "messages": [{"role": "user", "content": prompt}],
104
+ "temperature": 0.2,
105
+ "max_tokens": 512,
106
+ },
107
+ timeout=60,
108
+ )
109
+ if r.status_code != 200:
110
+ last_err = f"{model}: HTTP {r.status_code} {r.text[:200]}"
111
+ continue
112
+ content = r.json()["choices"][0]["message"]["content"]
113
+ parsed = _parse_reply(content)
114
+ if parsed["disease"] not in DISEASES:
115
+ # Accept model answer anyway but flag it
116
+ pass
117
+ parsed.update({
118
+ "success": True,
119
+ "missing": missing,
120
+ "accuracy": f"Groq AI ({model})",
121
+ })
122
+ return parsed
123
+ except Exception as e:
124
+ last_err = f"{model}: {e}"
125
+ continue
126
+
127
+ return {"success": False, "error": f"AI Prediction Failed: {last_err}"}
ocr_utils.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """OCR helpers for lab report images (EasyOCR + OpenCV + regex).
2
+
3
+ 13 extracted fields: Gender, Age, Hemoglobin, RBC, WBC, AST, ALT,
4
+ Cholesterol, Spirometry, Creatinine, Glucose, Lipase, Troponin.
5
+ Missing values are returned as None.
6
+ """
7
+ import re
8
+
9
+ _reader = None
10
+
11
+
12
+ def get_reader():
13
+ """Lazy singleton EasyOCR reader (downloads ~100MB weights on first call)."""
14
+ global _reader
15
+ if _reader is None:
16
+ import easyocr
17
+ _reader = easyocr.Reader(['en'], gpu=False)
18
+ return _reader
19
+
20
+
21
+ def ocr_image(image_path: str) -> str:
22
+ """Run OCR on an image file and return raw text."""
23
+ import cv2
24
+ reader = get_reader()
25
+ img = cv2.imread(image_path)
26
+ if img is None:
27
+ raise ValueError("Could not read image. Use JPG/PNG.")
28
+ # EasyOCR handles numpy arrays (BGR) directly.
29
+ results = reader.readtext(img)
30
+ return " ".join([t[1] for t in results])
31
+
32
+
33
+ def _num(patterns, text, cast=float):
34
+ for pat in patterns:
35
+ m = re.search(pat, text, re.IGNORECASE)
36
+ if m:
37
+ try:
38
+ return cast(m.group(1).replace(',', ''))
39
+ except (ValueError, IndexError):
40
+ continue
41
+ return None
42
+
43
+
44
+ def extract_lab_values(text: str) -> dict:
45
+ """Regex-extract the 13 lab fields from OCR text (None if not found)."""
46
+ t = text or ""
47
+ gender = None
48
+ if re.search(r'\bfemale\b', t, re.IGNORECASE):
49
+ gender = "Female"
50
+ elif re.search(r'\bmale\b', t, re.IGNORECASE):
51
+ gender = "Male"
52
+
53
+ return {
54
+ "Gender": gender,
55
+ "Age": _num([r'age\D{0,10}(\d{1,3})'], t, int),
56
+ "Hemoglobin": _num([r'h(?:ae)?moglobin\D{0,15}(\d+\.?\d*)', r'\bHb\D{0,10}(\d+\.?\d*)'], t),
57
+ "RBC": _num([r'\bRBC\D{0,10}(\d+\.?\d*)'], t),
58
+ "WBC": _num([r'\bWBC\D{0,10}([\d,]+\.?\d*)', r'W\.?B\.?C\.?\D{0,10}([\d,]+\.?\d*)'], t),
59
+ "AST": _num([r'\bAST\D{0,10}(\d+\.?\d*)', r'\bSGOT\D{0,10}(\d+\.?\d*)'], t),
60
+ "ALT": _num([r'\bALT\D{0,10}(\d+\.?\d*)', r'\bSGPT\D{0,10}(\d+\.?\d*)'], t),
61
+ "Cholesterol": _num([r'cholesterol\D{0,15}(\d+\.?\d*)'], t),
62
+ "Spirometry": _num([r'spirometry\D{0,15}(\d+\.?\d*)', r'\bFEV1?\D{0,10}(\d+\.?\d*)'], t),
63
+ "Creatinine": _num([r'creatinine\D{0,15}(\d+\.?\d*)'], t),
64
+ "Glucose": _num([r'glucose\D{0,15}(\d+\.?\d*)', r'blood sugar\D{0,15}(\d+\.?\d*)'], t),
65
+ "Lipase": _num([r'lipase\D{0,15}(\d+\.?\d*)'], t),
66
+ "Troponin": _num([r'troponin\D{0,15}(\d+\.?\d*)'], t),
67
+ }
requirements.txt CHANGED
@@ -17,5 +17,8 @@ python-dotenv
17
  # --- AI image model (CPU builds recommended, see Dockerfile) ---
18
  torch
19
  torchvision
 
 
 
20
  # --- Optional: GCP Cloud SQL Python Connector (only needed if USE_CLOUD_SQL_CONNECTOR=true) ---
21
  # cloud-sql-python-connector[pg8000]
 
17
  # --- AI image model (CPU builds recommended, see Dockerfile) ---
18
  torch
19
  torchvision
20
+ # --- Lab report OCR ---
21
+ opencv-python==4.8.1.78
22
+ easyocr==1.7.1
23
  # --- Optional: GCP Cloud SQL Python Connector (only needed if USE_CLOUD_SQL_CONNECTOR=true) ---
24
  # cloud-sql-python-connector[pg8000]
routes/doctor.py CHANGED
@@ -281,3 +281,8 @@ def gastro_analysis():
281
  @login_required
282
  def urology_analysis():
283
  return render_template('doctor/urology_analysis.html')
 
 
 
 
 
 
281
  @login_required
282
  def urology_analysis():
283
  return render_template('doctor/urology_analysis.html')
284
+
285
+ @doctor_bp.route('/lab_analysis')
286
+ @login_required
287
+ def lab_analysis():
288
+ return render_template('doctor/lab_analysis.html')
templates/doctor/lab_analysis.html ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends 'layout.html' %}
2
+ {% block content %}
3
+ <div class="container-fluid py-4">
4
+ <div class="row mb-4">
5
+ <div class="col-12">
6
+ <h2>Lab Report Analysis</h2>
7
+ <p class="text-muted">Upload a lab report image to auto-fill values, verify them, then predict disease (Groq AI).</p>
8
+ </div>
9
+ </div>
10
+ <div class="row justify-content-center">
11
+ <div class="col-12 col-xl-10">
12
+ <div class="card shadow-sm mb-4">
13
+ <div class="card-body p-4">
14
+ <h5 class="card-title mb-3"><i data-lucide="upload" class="me-2"></i>1. Upload Lab Report (JPG/PNG)</h5>
15
+ <div class="d-flex gap-2 align-items-center flex-wrap">
16
+ <input type="file" id="lab-file" class="form-control" accept=".jpg,.jpeg,.png,image/*" style="max-width: 400px;">
17
+ <button type="button" class="btn btn-secondary" id="btn-extract">
18
+ <i data-lucide="scan" class="me-2"></i>Extract Values
19
+ </button>
20
+ <span class="text-muted small" id="extract-status">No file processed.</span>
21
+ </div>
22
+ </div>
23
+ </div>
24
+
25
+ <form id="labForm">
26
+ <div class="card shadow-sm mb-4">
27
+ <div class="card-body p-4">
28
+ <h5 class="card-title mb-3"><i data-lucide="clipboard-check" class="me-2"></i>2. Verify / Enter Values</h5>
29
+ <div class="row g-2">
30
+ <div class="col-md-3"><label class="form-label">Gender</label><select class="form-select" id="Gender"><option value="">--</option><option>Male</option><option>Female</option></select></div>
31
+ <div class="col-md-3"><label class="form-label">Age</label><input type="number" class="form-control" id="Age" step="1"></div>
32
+ <div class="col-md-3"><label class="form-label">Hemoglobin</label><input type="number" class="form-control" id="Hemoglobin" step="0.1"></div>
33
+ <div class="col-md-3"><label class="form-label">RBC</label><input type="number" class="form-control" id="RBC" step="0.01"></div>
34
+ <div class="col-md-3"><label class="form-label">WBC</label><input type="number" class="form-control" id="WBC" step="1"></div>
35
+ <div class="col-md-3"><label class="form-label">AST</label><input type="number" class="form-control" id="AST" step="0.1"></div>
36
+ <div class="col-md-3"><label class="form-label">ALT</label><input type="number" class="form-control" id="ALT" step="0.1"></div>
37
+ <div class="col-md-3"><label class="form-label">Cholesterol</label><input type="number" class="form-control" id="Cholesterol" step="1"></div>
38
+ <div class="col-md-3"><label class="form-label">Spirometry</label><input type="number" class="form-control" id="Spirometry" step="0.01"></div>
39
+ <div class="col-md-3"><label class="form-label">Creatinine</label><input type="number" class="form-control" id="Creatinine" step="0.01"></div>
40
+ <div class="col-md-3"><label class="form-label">Glucose</label><input type="number" class="form-control" id="Glucose" step="1"></div>
41
+ <div class="col-md-3"><label class="form-label">Lipase</label><input type="number" class="form-control" id="Lipase" step="1"></div>
42
+ <div class="col-md-3"><label class="form-label">Troponin</label><input type="number" class="form-control" id="Troponin" step="0.001"></div>
43
+ </div>
44
+ <div class="d-flex justify-content-end mt-3">
45
+ <button type="button" class="btn btn-primary px-4 py-2 fw-bold shadow-sm" id="btn-predict-lab">
46
+ <i data-lucide="cpu" class="me-2"></i>Predict Disease
47
+ </button>
48
+ </div>
49
+ </div>
50
+ </div>
51
+ </form>
52
+
53
+ <div id="lab-result" style="display:none;" class="card shadow-sm border-0">
54
+ <div class="card-body p-4">
55
+ <h5 class="card-title text-primary border-bottom pb-2 mb-3"><i data-lucide="activity" class="me-2"></i>Prediction Result</h5>
56
+ <div class="alert alert-info mb-3">
57
+ <div class="small text-muted text-uppercase fw-bold">Predicted Disease</div>
58
+ <h4 id="lab-disease" class="mb-1"></h4>
59
+ <small class="text-muted">Confidence: <span id="lab-confidence"></span>% (<span id="lab-model"></span>)</small>
60
+ </div>
61
+ <h6>Top predictions</h6>
62
+ <ul id="lab-top3" class="list-group mb-0"></ul>
63
+ </div>
64
+ </div>
65
+ </div>
66
+ </div>
67
+ </div>
68
+
69
+ <script>
70
+ const LAB_FIELDS = ["Gender","Age","Hemoglobin","RBC","WBC","AST","ALT","Cholesterol","Spirometry","Creatinine","Glucose","Lipase","Troponin"];
71
+
72
+ document.getElementById('btn-extract').addEventListener('click', function () {
73
+ const fileInput = document.getElementById('lab-file');
74
+ const status = document.getElementById('extract-status');
75
+ if (!fileInput.files.length) { alert('Select an image first.'); return; }
76
+ const btn = this; btn.disabled = true; status.innerText = 'Running OCR...';
77
+ const fd = new FormData(); fd.append('file', fileInput.files[0]);
78
+ fetch('/api/extract_lab_report', { method: 'POST', body: fd })
79
+ .then(r => r.json())
80
+ .then(data => {
81
+ btn.disabled = false;
82
+ if (data.success) {
83
+ const v = data.extracted_values || {};
84
+ LAB_FIELDS.forEach(f => {
85
+ const el = document.getElementById(f);
86
+ if (el && v[f] !== null && v[f] !== undefined) el.value = v[f];
87
+ });
88
+ status.innerText = 'Values extracted — please verify.';
89
+ } else {
90
+ status.innerText = 'Extract failed: ' + data.error;
91
+ }
92
+ })
93
+ .catch(e => { btn.disabled = false; status.innerText = 'Error: ' + e; });
94
+ });
95
+
96
+ document.getElementById('btn-predict-lab').addEventListener('click', function () {
97
+ const payload = {};
98
+ LAB_FIELDS.forEach(f => { payload[f] = document.getElementById(f).value; });
99
+ const btn = this; btn.disabled = true; btn.innerText = 'Predicting...';
100
+ fetch('/api/predict_lab_report', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload) })
101
+ .then(r => r.json())
102
+ .then(data => {
103
+ btn.disabled = false; btn.innerHTML = 'Predict Disease';
104
+ if (data.success) {
105
+ document.getElementById('lab-result').style.display = 'block';
106
+ document.getElementById('lab-disease').innerText = data.disease;
107
+ document.getElementById('lab-confidence').innerText = data.confidence;
108
+ document.getElementById('lab-model').innerText = data.accuracy || '';
109
+ const ul = document.getElementById('lab-top3'); ul.innerHTML = '';
110
+ (data.top3 || []).forEach(t => {
111
+ const li = document.createElement('li');
112
+ li.className = 'list-group-item d-flex justify-content-between';
113
+ li.innerHTML = '<span>' + t.disease + '</span><strong>' + t.confidence + '%</strong>';
114
+ ul.appendChild(li);
115
+ });
116
+ document.getElementById('lab-result').scrollIntoView({ behavior: 'smooth' });
117
+ } else {
118
+ alert('AI Prediction Failed: ' + data.error);
119
+ }
120
+ })
121
+ .catch(e => { btn.disabled = false; alert('Error: ' + e); });
122
+ });
123
+ </script>
124
+ {% endblock %}
templates/layout.html CHANGED
@@ -49,6 +49,12 @@
49
  <span>Urology AI Form</span>
50
  </a>
51
  </li>
 
 
 
 
 
 
52
  {% elif current_user.role == 'patient' %}
53
  <li class="sidebar-menu-item {% if request.endpoint == 'patient.dashboard' %}active{% endif %}">
54
  <a href="{{ url_for('patient.dashboard') }}">
 
49
  <span>Urology AI Form</span>
50
  </a>
51
  </li>
52
+ <li class="sidebar-menu-item {% if request.endpoint == 'doctor.lab_analysis' %}active{% endif %}">
53
+ <a href="{{ url_for('doctor.lab_analysis') }}">
54
+ <i data-lucide="flask-conical"></i>
55
+ <span>Lab Report</span>
56
+ </a>
57
+ </li>
58
  {% elif current_user.role == 'patient' %}
59
  <li class="sidebar-menu-item {% if request.endpoint == 'patient.dashboard' %}active{% endif %}">
60
  <a href="{{ url_for('patient.dashboard') }}">