dpv007 commited on
Commit
9a09ec4
·
verified ·
1 Parent(s): 2d03b8b

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +472 -777
app.py CHANGED
@@ -1,19 +1,6 @@
1
- # app.py
2
  """
3
- Elderly HealthWatch AI Backend (FastAPI)
4
- Pipeline:
5
- - receive images
6
- - run VLM (remote gradio / chat_fn) -> JSON feature vector + raw text
7
- - run LLM (remote gradio /chat) -> structured risk JSON (per requested schema)
8
- - continue rest of processing and store results
9
-
10
- Notes:
11
- - Add gradio_client==1.13.2 (or another compatible 1.x) to requirements.txt
12
- - If VLM/LLM Spaces are private, set HF_TOKEN in the environment for authentication.
13
- - This version includes a robust regex-based extractor that finds the outermost {...} block
14
- in the LLM output, extracts numeric values for the required keys, and always returns
15
- numeric defaults (no NaN) so frontends will not receive null/None for numeric fields.
16
- - This variant logs raw LLM output and the parsed JSON using Python logging.
17
  """
18
 
19
  import io
@@ -31,272 +18,293 @@ from fastapi import FastAPI, UploadFile, File, BackgroundTasks, HTTPException
31
  from fastapi.middleware.cors import CORSMiddleware
32
  from PIL import Image
33
  import numpy as np
34
- import cv2 # opencv-python-headless expected installed
35
 
36
- # Optional gradio client (for VLM + LLM calls)
37
  try:
38
- from gradio_client import Client, handle_file # type: ignore
39
  GRADIO_AVAILABLE = True
40
  except Exception:
41
  GRADIO_AVAILABLE = False
42
 
43
- # Configure logging
 
 
44
  logging.basicConfig(level=logging.INFO)
45
  logger = logging.getLogger("elderly_healthwatch")
46
 
47
- # Configuration for remote VLM and LLM spaces (change to your target Space names)
48
  GRADIO_VLM_SPACE = os.getenv("GRADIO_SPACE", "developer0hye/Qwen3-VL-8B-Instruct")
49
  LLM_GRADIO_SPACE = os.getenv("LLM_GRADIO_SPACE", "Tonic/med-gpt-oss-20b-demo")
50
  HF_TOKEN = os.getenv("HF_TOKEN", None)
51
 
52
- # Default VLM prompt
53
  DEFAULT_VLM_PROMPT = (
54
  "From the provided face/eye images, compute the required screening features "
55
  "(pallor, sclera yellowness, redness, mobility metrics, quality checks) "
56
  "and output a clean JSON feature vector only."
57
  )
58
 
59
- # Default LLM prompts / metadata (stricter: force JSON-only output)
60
- LLM_MODEL_IDENTITY = os.getenv(
61
- "LLM_MODEL_IDENTITY",
62
- "You are GPT-Tonic, a large language model trained by TonicAI for clinical reasoning."
63
  )
64
- LLM_SYSTEM_PROMPT = os.getenv(
65
- "LLM_SYSTEM_PROMPT",
66
- "System: This assistant MUST ONLY OUTPUT a single valid JSON object as its response — no prose, no explanations, no code fences, no annotations. The JSON must follow the schema requested by the user."
67
- )
68
- LLM_DEVELOPER_PROMPT = os.getenv(
69
- "LLM_DEVELOPER_PROMPT",
70
- "Developer: Output ONLY a single valid JSON object with keys: risk_score, jaundice_probability, anemia_probability, hydration_issue_probability, neurological_issue_probability, summary, recommendation, confidence. Do NOT include any extra fields or natural language outside the JSON object."
71
  )
72
 
73
- # Try MTCNN libs; fallback to OpenCV haar cascades
74
- _MTCNN_IMPL = None
75
- try:
76
- from facenet_pytorch import MTCNN as FacenetMTCNN # type: ignore
77
- _MTCNN_IMPL = "facenet_pytorch"
78
- except Exception:
79
- FacenetMTCNN = None
80
- _MTCNN_IMPL = None
81
 
82
- if _MTCNN_IMPL is None:
 
 
 
 
 
 
 
 
83
  try:
84
- from mtcnn import MTCNN as ClassicMTCNN # type: ignore
85
- _MTCNN_IMPL = "mtcnn"
86
  except Exception:
87
- ClassicMTCNN = None
88
-
89
- def create_mtcnn_or_fallback():
90
- if _MTCNN_IMPL == "facenet_pytorch" and FacenetMTCNN is not None:
91
- try:
92
- return FacenetMTCNN(keep_all=False, device="cpu")
93
- except Exception:
94
- pass
95
- if _MTCNN_IMPL == "mtcnn" and ClassicMTCNN is not None:
96
- try:
97
- return ClassicMTCNN()
98
- except Exception:
99
- pass
100
- # OpenCV Haar fallback
101
  try:
102
- face_cascade_path = os.path.join(cv2.data.haarcascades, "haarcascade_frontalface_default.xml")
103
- eye_cascade_path = os.path.join(cv2.data.haarcascades, "haarcascade_eye.xml")
104
- if os.path.exists(face_cascade_path) and os.path.exists(eye_cascade_path):
 
 
 
 
 
 
 
105
  return {
106
  "impl": "opencv",
107
- "face_cascade": cv2.CascadeClassifier(face_cascade_path),
108
- "eye_cascade": cv2.CascadeClassifier(eye_cascade_path)
109
- }
110
  except Exception:
111
  pass
112
- return None
113
-
114
- mtcnn = create_mtcnn_or_fallback()
115
-
116
- app = FastAPI(title="Elderly HealthWatch AI Backend")
117
- app.add_middleware(
118
- CORSMiddleware,
119
- allow_origins=["*"],
120
- allow_credentials=True,
121
- allow_methods=["*"],
122
- allow_headers=["*"],
123
- )
124
 
125
- # In-memory DB for demo
126
- screenings_db: Dict[str, Dict[str, Any]] = {}
127
 
128
- # -----------------------
129
- # Utility helpers
130
- # -----------------------
131
  def load_image_from_bytes(bytes_data: bytes) -> Image.Image:
132
  return Image.open(io.BytesIO(bytes_data)).convert("RGB")
133
 
134
- def estimate_eye_openness_from_detection(confidence: float) -> float:
135
- try:
136
- conf = float(confidence)
137
- openness = min(max((conf * 1.15), 0.0), 1.0)
138
- return openness
139
- except Exception:
140
  return 0.0
141
-
142
- # -----------------------
143
- # Regex-based robust extractor
144
- # -----------------------
145
- def extract_json_via_regex(raw_text: str) -> Dict[str, Any]:
146
- """
147
- 1) Finds the outermost { ... } block in raw_text.
148
- 2) Extracts numeric values after the listed keys using regex, tolerating:
149
- - quotes, spaces, percent signs, percent numbers like "55%", strings like "0.12", integers, or numbers in quotes.
150
- 3) Returns a dict with numeric fields GUARANTEED to be floats (no None/NaN), and string fields for summary/recommendation.
151
- """
152
- # Find the first {...} block (outermost approximation)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
  match = re.search(r"\{[\s\S]*\}", raw_text)
154
  if not match:
155
  raise ValueError("No JSON-like block found in LLM output")
156
-
157
  block = match.group(0)
158
-
159
- def find_number_for_key(key: str) -> Optional[float]:
160
- """
161
- Returns a float in range 0..1 for probabilities, and raw numeric for other keys depending on usage.
162
- This helper returns None if not found; caller will replace with defaults (0.0).
163
- """
164
- # Try multiple patterns to be robust
165
- # Pattern captures numbers possibly with % and optional quotes, e.g. "45%", '0.12', 0.5, " 87 "
166
  patterns = [
167
- rf'"{key}"\s*:\s*["\']?\s*([-+]?\d+(\.\d+)?)\s*%?\s*["\']?', # "key": "45%" or "key": 0.45
168
  rf"'{key}'\s*:\s*['\"]?\s*([-+]?\d+(\.\d+)?)\s*%?\s*['\"]?",
169
- rf'\b{key}\b\s*:\s*["\']?\s*([-+]?\d+(\.\d+)?)\s*%?\s*["\']?', # key: 45%
170
- rf'"{key}"\s*:\s*["\']([^"\']+)["\']', # capture quoted text (for non-numeric attempts)
171
- rf"'{key}'\s*:\s*['\"]([^'\"]+)['\"]"
172
  ]
173
  for pat in patterns:
174
  m = re.search(pat, block, flags=re.IGNORECASE)
175
- if not m:
176
- continue
177
- g = m.group(1)
178
- if g is None:
179
- continue
180
- s = str(g).strip()
181
- # Remove percent sign if present
182
- s = s.replace("%", "").strip()
183
- # Try to coerce to float
184
- try:
185
- val = float(s)
186
- return val
187
- except Exception:
188
- # not numeric
189
- return None
190
  return None
191
-
192
- def find_text_for_key(key: str) -> str:
193
- # capture "key": "some text" allowing single/double quotes and also unquoted until comma/}
194
  m = re.search(rf'"{key}"\s*:\s*"([^"]*)"', block, flags=re.IGNORECASE)
195
  if m:
196
  return m.group(1).strip()
197
  m = re.search(rf"'{key}'\s*:\s*'([^']*)'", block, flags=re.IGNORECASE)
198
  if m:
199
  return m.group(1).strip()
200
- # fallback: key: some text (unquoted) up to comma or }
201
  m = re.search(rf'\b{key}\b\s*:\s*([^\n,}}]+)', block, flags=re.IGNORECASE)
202
  if m:
203
  return m.group(1).strip().strip('",')
204
  return ""
205
-
206
- # Extract raw numeric candidates
207
- raw_risk = find_number_for_key("risk_score")
208
- raw_jaundice = find_number_for_key("jaundice_probability")
209
- raw_anemia = find_number_for_key("anemia_probability")
210
- raw_hydration = find_number_for_key("hydration_issue_probability")
211
- raw_neuro = find_number_for_key("neurological_issue_probability")
212
- raw_conf = find_number_for_key("confidence")
213
-
214
- # Normalize:
215
- # - For probabilities: if value > 1 and <=100 => treat as percent -> divide by 100. If <=1 treat as fraction.
216
- def normalize_prob(v: Optional[float]) -> float:
217
- if v is None:
218
- return 0.0
219
- if v > 1.0 and v <= 100.0:
220
- return max(0.0, min(1.0, v / 100.0))
221
- # if v is large >100, clamp to 1.0
222
- if v > 100.0:
223
- return 1.0
224
- # otherwise assume already 0..1
225
- return max(0.0, min(1.0, v))
226
-
227
- jaundice_probability = normalize_prob(raw_jaundice)
228
- anemia_probability = normalize_prob(raw_anemia)
229
- hydration_issue_probability = normalize_prob(raw_hydration)
230
- neurological_issue_probability = normalize_prob(raw_neuro)
231
- confidence = normalize_prob(raw_conf)
232
-
233
- # risk_score: return in 0..100
234
- def normalize_risk(v: Optional[float]) -> float:
235
- if v is None:
236
- return 0.0
237
- if v <= 1.0:
238
- # fraction given -> scale to 0..100
239
- return round(max(0.0, min(100.0, v * 100.0)), 2)
240
- # if between 1 and 100, assume it's already 0..100
241
- if v > 1.0 and v <= 100.0:
242
- return round(max(0.0, min(100.0, v)), 2)
243
- # clamp anything insane
244
- return round(max(0.0, min(100.0, v if v < float('inf') else 100.0)), 2)
245
-
246
- risk_score = normalize_risk(raw_risk)
247
-
248
- summary = find_text_for_key("summary")
249
- recommendation = find_text_for_key("recommendation")
250
-
251
- out = {
252
- "risk_score": risk_score,
253
- "jaundice_probability": round(jaundice_probability, 4),
254
- "anemia_probability": round(anemia_probability, 4),
255
- "hydration_issue_probability": round(hydration_issue_probability, 4),
256
- "neurological_issue_probability": round(neurological_issue_probability, 4),
257
- "confidence": round(confidence, 4),
258
- "summary": summary,
259
- "recommendation": recommendation
260
  }
261
- return out
262
 
263
- # -----------------------
264
- # Gradio / VLM helper (returns parsed dict OR None, plus raw text)
265
- # -----------------------
266
- def get_gradio_client_for_space(space: str) -> Client:
 
267
  if not GRADIO_AVAILABLE:
268
- raise RuntimeError("gradio_client not installed in this environment. Add gradio_client to requirements.txt.")
269
- if HF_TOKEN:
270
- return Client(space, hf_token=HF_TOKEN)
271
- return Client(space)
272
-
273
- def run_vlm_and_get_features(face_path: str, eye_path: str, prompt: Optional[str] = None) -> Tuple[Optional[Dict[str, Any]], str]:
274
- """
275
- Synchronous call to remote VLM (gradio /chat_fn). Returns tuple:
276
- (parsed_features_dict_or_None, raw_text_response_str)
277
-
278
- We attempt to parse JSON as before, but always return the original raw text so it can be
279
- forwarded verbatim to the LLM if desired.
280
- """
281
  prompt = prompt or DEFAULT_VLM_PROMPT
 
282
  if not os.path.exists(face_path) or not os.path.exists(eye_path):
283
- raise FileNotFoundError("Face or eye image path missing for VLM call.")
284
- if not GRADIO_AVAILABLE:
285
- raise RuntimeError("gradio_client not available in this environment.")
286
-
287
- client = get_gradio_client_for_space(GRADIO_VLM_SPACE)
288
  message = {"text": prompt, "files": [handle_file(face_path), handle_file(eye_path)]}
289
-
290
  try:
291
- logger.info("Calling VLM Space %s", GRADIO_VLM_SPACE)
292
  result = client.predict(message=message, history=[], api_name="/chat_fn")
293
  except Exception as e:
294
  logger.exception("VLM call failed")
295
  raise RuntimeError(f"VLM call failed: {e}")
296
-
297
- if not result:
298
- raise RuntimeError("Empty response from VLM")
299
-
300
  # Normalize result
301
  if isinstance(result, (list, tuple)):
302
  out = result[0]
@@ -304,224 +312,184 @@ def run_vlm_and_get_features(face_path: str, eye_path: str, prompt: Optional[str
304
  out = result
305
  else:
306
  out = {"text": str(result)}
307
-
308
- if not isinstance(out, dict):
309
- raise RuntimeError("Unexpected VLM output format (expected dict with 'text' key)")
310
-
311
- text_out = out.get("text") or out.get("output") or None
312
- if not text_out:
313
- text_out = json.dumps(out)
314
-
315
- # Try to parse JSON but remember raw text always
316
- parsed_features = None
317
  try:
318
- parsed_features = json.loads(text_out)
319
- if not isinstance(parsed_features, dict):
320
- parsed_features = None
321
  except Exception:
 
322
  try:
323
- s = text_out
324
- first = s.find("{")
325
- last = s.rfind("}")
326
  if first != -1 and last != -1 and last > first:
327
- maybe = s[first:last+1]
328
- parsed_features = json.loads(maybe)
329
- if not isinstance(parsed_features, dict):
330
- parsed_features = None
331
- else:
332
- parsed_features = None
333
  except Exception:
334
- parsed_features = None
335
-
336
- return parsed_features, text_out
337
-
338
- # -----------------------
339
- # Gradio / LLM helper (defensive, with retry + clamps)
340
- # -----------------------
341
- def run_llm_on_vlm(vlm_features_or_raw: Any,
342
- max_new_tokens: int = 1024,
343
- temperature: float = 0.0,
344
- reasoning_effort: str = "medium",
345
- model_identity: Optional[str] = None,
346
- system_prompt: Optional[str] = None,
347
- developer_prompt: Optional[str] = None) -> Dict[str, Any]:
348
- """
349
- Call the remote LLM Space's /chat endpoint with defensive input handling and a single retry.
350
- - Coerces types (int for tokens), clamps ranges where remote spaces often expect them.
351
- - Retries once with safe defaults if the Space rejects the inputs (e.g. temperature too low).
352
- - Logs and returns regex-extracted JSON as before.
353
- """
354
- if not GRADIO_AVAILABLE:
355
- raise RuntimeError("gradio_client not installed. Add gradio_client to requirements.txt")
356
-
357
- # Try to import AppError for specific handling; fallback to Exception if unavailable
358
- try:
359
- from gradio_client import AppError # type: ignore
360
- except Exception:
361
- AppError = Exception # fallback
362
 
363
- client = get_gradio_client_for_space(LLM_GRADIO_SPACE)
364
- model_identity = model_identity or LLM_MODEL_IDENTITY
365
- system_prompt = system_prompt or LLM_SYSTEM_PROMPT
366
- developer_prompt = developer_prompt or LLM_DEVELOPER_PROMPT
367
-
368
- # Prepare the combined prompt: use raw string as-is, otherwise json.dumps the dict
369
- if isinstance(vlm_features_or_raw, str):
370
- vlm_json_str = vlm_features_or_raw
371
- else:
372
- vlm_json_str = json.dumps(vlm_features_or_raw, default=str)
373
-
374
- # Strong, explicit instruction to output only JSON
375
  instruction = (
376
- "\n\nSTRICT INSTRUCTIONS (READ CAREFULLY):\n"
377
- "1) OUTPUT ONLY a single valid JSON object and nothing else — no prose, no explanation, no code fences.\n"
378
- "2) The JSON MUST include these keys: risk_score, jaundice_probability, anemia_probability, "
379
  "hydration_issue_probability, neurological_issue_probability, summary, recommendation, confidence.\n"
380
- "3) Use numeric values for probabilities (0..1) and for risk_score (0..100). Use strings for summary and recommendation.\n"
381
- "4) Do NOT mention disease names in summary or recommendation; use neutral wording only.\n"
382
- "If you cannot estimate a value, set it to null.\n\n"
383
- "Now, based on the VLM output below, produce ONLY the JSON object described above.\n\n"
384
- "===BEGIN VLM OUTPUT===\n"
385
- f"{vlm_json_str}\n"
386
- "===END VLM OUTPUT===\n\n"
387
- )
388
- input_payload_str = instruction
389
-
390
- # Defensive coercion / clamps
391
- try_max_new_tokens = int(max_new_tokens) if max_new_tokens is not None else 1024
392
- if try_max_new_tokens <= 0:
393
- try_max_new_tokens = 1024
394
-
395
- try_temperature = float(temperature) if temperature is not None else 0.0
396
- # Many demos require temperature >= 0.1; clamp to 0.1 minimum to avoid validation failures
397
- if try_temperature < 0.1:
398
- try_temperature = 0.1
399
-
400
- # prepare kwargs for predict
401
- predict_kwargs = dict(
402
- input_data=input_payload_str,
403
- max_new_tokens=float(try_max_new_tokens),
404
- model_identity=model_identity,
405
- system_prompt=system_prompt,
406
- developer_prompt=developer_prompt,
407
- reasoning_effort=reasoning_effort,
408
- temperature=float(try_temperature),
409
- top_p=0.9,
410
- top_k=50,
411
- repetition_penalty=1.0,
412
- api_name="/chat"
413
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
414
 
415
- # attempt + one retry with safer defaults if AppError occurs
416
- last_exc = None
417
- for attempt in (1, 2):
418
- try:
419
- logger.info("Calling LLM Space %s (attempt %d) with temperature=%s, max_new_tokens=%s",
420
- LLM_GRADIO_SPACE, attempt, predict_kwargs.get("temperature"), predict_kwargs.get("max_new_tokens"))
421
- result = client.predict(**predict_kwargs)
422
- # normalize to string
423
- if isinstance(result, (dict, list)):
424
- text_out = json.dumps(result)
425
- else:
426
- text_out = str(result)
427
- if not text_out or len(text_out.strip()) == 0:
428
- raise RuntimeError("LLM returned empty response")
429
- logger.info("LLM raw output:\n%s", text_out)
430
-
431
- # parse with regex extractor (may raise)
432
- parsed = extract_json_via_regex(text_out)
433
- if not isinstance(parsed, dict):
434
- raise ValueError("Parsed LLM output is not a JSON object/dict")
435
-
436
- # pretty log parsed JSON
437
- try:
438
- logger.info("LLM parsed JSON:\n%s", json.dumps(parsed, indent=2, ensure_ascii=False))
439
- except Exception:
440
- logger.info("LLM parsed JSON (raw dict): %s", str(parsed))
441
-
442
- # defensive clamps (same as before)
443
- def safe_prob(val):
444
- try:
445
- v = float(val)
446
- return max(0.0, min(1.0, v))
447
- except Exception:
448
- return 0.0
449
-
450
- for k in [
451
- "jaundice_probability",
452
- "anemia_probability",
453
- "hydration_issue_probability",
454
- "neurological_issue_probability"
455
- ]:
456
- parsed[k] = safe_prob(parsed.get(k, 0.0))
457
-
458
- try:
459
- rs = float(parsed.get("risk_score", 0.0))
460
- parsed["risk_score"] = round(max(0.0, min(100.0, rs)), 2)
461
- except Exception:
462
- parsed["risk_score"] = 0.0
463
-
464
- parsed["confidence"] = safe_prob(parsed.get("confidence", 0.0))
465
- parsed["summary"] = str(parsed.get("summary", "") or "").strip()
466
- parsed["recommendation"] = str(parsed.get("recommendation", "") or "").strip()
467
-
468
- for k in [
469
- "jaundice_probability",
470
- "anemia_probability",
471
- "hydration_issue_probability",
472
- "neurological_issue_probability",
473
- "confidence",
474
- "risk_score"
475
- ]:
476
- parsed[f"{k}_was_missing"] = False
477
-
478
- return parsed
479
-
480
- except AppError as app_e:
481
- # Specific remote validation error: log and attempt a single retry with ultra-safe defaults
482
- logger.exception("LLM AppError (remote validation failed) on attempt %d: %s", attempt, str(app_e))
483
- last_exc = app_e
484
- if attempt == 1:
485
- # tighten inputs and retry: force temperature=0.2, max_new_tokens=512
486
- predict_kwargs["temperature"] = 0.2
487
- predict_kwargs["max_new_tokens"] = float(512)
488
- logger.info("Retrying LLM call with temperature=0.2 and max_new_tokens=512")
489
- continue
490
- else:
491
- # no more retries
492
- raise RuntimeError(f"LLM call failed (AppError): {app_e}")
493
- except Exception as e:
494
- logger.exception("LLM call failed on attempt %d: %s", attempt, str(e))
495
- last_exc = e
496
- # try one retry only for non-AppError exceptions
497
- if attempt == 1:
498
- predict_kwargs["temperature"] = 0.2
499
- predict_kwargs["max_new_tokens"] = float(512)
500
- continue
501
- raise RuntimeError(f"LLM call failed: {e}")
502
-
503
- # if we reach here, raise last caught exception
504
- raise RuntimeError(f"LLM call ultimately failed: {last_exc}")
505
-
506
- # -----------------------
507
- # API endpoints
508
- # -----------------------
509
  @app.get("/")
510
  async def read_root():
511
  return {"message": "Elderly HealthWatch AI Backend"}
512
 
513
  @app.get("/health")
514
  async def health_check():
515
- impl = None
516
- if mtcnn is None:
517
- impl = "none"
518
- elif isinstance(mtcnn, dict) and mtcnn.get("impl") == "opencv":
519
- impl = "opencv_haar_fallback"
520
- else:
521
- impl = _MTCNN_IMPL
522
  return {
523
  "status": "healthy",
524
- "detector": impl,
525
  "vlm_available": GRADIO_AVAILABLE,
526
  "vlm_space": GRADIO_VLM_SPACE,
527
  "llm_space": LLM_GRADIO_SPACE
@@ -529,107 +497,46 @@ async def health_check():
529
 
530
  @app.post("/api/v1/validate-eye-photo")
531
  async def validate_eye_photo(image: UploadFile = File(...)):
532
- """
533
- Lightweight validation endpoint. Uses available detector (facenet/mtcnn/opencv) to check face/eye detection.
534
- For full pipeline, use /api/v1/upload which invokes VLM+LLM in background.
535
- """
536
- if mtcnn is None:
537
- raise HTTPException(status_code=500, detail="No face detector available in this deployment.")
538
-
539
  try:
540
  content = await image.read()
541
  if not content:
542
- raise HTTPException(status_code=400, detail="Empty file uploaded.")
 
543
  pil_img = load_image_from_bytes(content)
544
- img_arr = np.asarray(pil_img) # RGB
545
-
546
- # facenet-pytorch branch
547
- if not isinstance(mtcnn, dict) and _MTCNN_IMPL == "facenet_pytorch":
548
- try:
549
- boxes, probs, landmarks = mtcnn.detect(pil_img, landmarks=True)
550
- if boxes is None or len(boxes) == 0:
551
- return {"valid": False, "face_detected": False, "eye_openness_score": 0.0,
552
- "message_english": "No face detected. Please ensure your face is clearly visible in the frame.",
553
- "message_hindi": "कोई चेहरा नहीं मिला। कृपया सुनिश्चित करें कि आपका चेहरा फ्रेम में स्पष्ट रूप से दिखाई दे रहा है।"}
554
- prob = float(probs[0]) if probs is not None else 0.0
555
- lm = landmarks[0] if landmarks is not None else None
556
- if lm is not None and len(lm) >= 2:
557
- left_eye = {"x": float(lm[0][0]), "y": float(lm[0][1])}
558
- right_eye = {"x": float(lm[1][0]), "y": float(lm[1][1])}
559
- else:
560
- left_eye = right_eye = None
561
- eye_openness_score = estimate_eye_openness_from_detection(prob)
562
- is_valid = eye_openness_score >= 0.3
563
- return {"valid": bool(is_valid), "face_detected": True, "eye_openness_score": round(eye_openness_score, 2),
564
- "message_english": "Photo looks good! Eyes are properly open." if is_valid else "Eyes appear to be closed or partially closed. Please open your eyes wide and try again.",
565
- "message_hindi": "फोटो अच्छी है! आंखें ठीक से खुली हैं।" if is_valid else "आंखें बंद या आंशिक रूप से बंद दिखाई दे रही हैं। कृपया अपनी आंखें चौड��ी खोलें और पुनः प्रयास करें।",
566
- "eye_landmarks": {"left_eye": left_eye, "right_eye": right_eye}}
567
- except Exception:
568
- traceback.print_exc()
569
- raise HTTPException(status_code=500, detail="Face detector failed during inference.")
570
-
571
- # classic mtcnn branch
572
- if not isinstance(mtcnn, dict) and _MTCNN_IMPL == "mtcnn":
573
- try:
574
- detections = mtcnn.detect_faces(img_arr)
575
- except Exception:
576
- detections = mtcnn.detect_faces(pil_img)
577
- if not detections:
578
- return {"valid": False, "face_detected": False, "eye_openness_score": 0.0,
579
- "message_english": "No face detected. Please ensure your face is clearly visible in the frame.",
580
- "message_hindi": "कोई चेहरा नहीं मिला। कृपया सुनिश्चित करें कि आपका चेहरा फ्रेम में स्पष्ट रूप से दिखाई दे रहा है।"}
581
- face = detections[0]
582
- keypoints = face.get("keypoints", {})
583
- left_eye = keypoints.get("left_eye")
584
- right_eye = keypoints.get("right_eye")
585
- confidence = float(face.get("confidence", 0.0))
586
- eye_openness_score = estimate_eye_openness_from_detection(confidence)
587
- is_valid = eye_openness_score >= 0.3
588
- return {"valid": bool(is_valid), "face_detected": True, "eye_openness_score": round(eye_openness_score, 2),
589
- "message_english": "Photo looks good! Eyes are properly open." if is_valid else "Eyes appear to be closed or partially closed. Please open your eyes wide and try again.",
590
- "message_hindi": "फोटो अच्छी है! आंखें ठीक से खुली हैं।" if is_valid else "आंखें बंद या आंशिक रूप से बंद दिखाई दे रही हैं। कृपया अपनी आंखें चौड़ी खोलें और पुनः प्रयास करें।",
591
- "eye_landmarks": {"left_eye": left_eye, "right_eye": right_eye}}
592
-
593
- # OpenCV Haar cascade fallback
594
- if isinstance(mtcnn, dict) and mtcnn.get("impl") == "opencv":
595
- try:
596
- gray = cv2.cvtColor(img_arr, cv2.COLOR_RGB2GRAY)
597
- face_cascade = mtcnn["face_cascade"]
598
- eye_cascade = mtcnn["eye_cascade"]
599
- faces = face_cascade.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=4, minSize=(60, 60))
600
- if len(faces) == 0:
601
- return {"valid": False, "face_detected": False, "eye_openness_score": 0.0,
602
- "message_english": "No face detected. Please ensure your face is clearly visible in the frame.",
603
- "message_hindi": "कोई चेहरा नहीं मिला। कृपया सुनिश्चित करें कि आपका चेहरा फ्रेम में स्पष्ट रूप से दिखाई दे रहा है।"}
604
- (x, y, w, h) = faces[0]
605
- roi_gray = gray[y:y+h, x:x+w]
606
- eyes = eye_cascade.detectMultiScale(roi_gray, scaleFactor=1.1, minNeighbors=5, minSize=(20, 10))
607
- eye_openness_score = 1.0 if len(eyes) >= 1 else 0.0
608
- is_valid = eye_openness_score >= 0.3
609
- left_eye = None
610
- right_eye = None
611
- if len(eyes) >= 1:
612
- ex, ey, ew, eh = eyes[0]
613
- cx = float(x + ex + ew/2)
614
- cy = float(y + ey + eh/2)
615
- left_eye = {"x": cx, "y": cy}
616
- return {"valid": bool(is_valid), "face_detected": True, "eye_openness_score": round(eye_openness_score, 2),
617
- "message_english": "Photo looks good! Eyes are detected." if is_valid else "Eyes not detected. Please open your eyes wide and try again.",
618
- "message_hindi": "फोटो अच्छी है! आंखें मिलीं।" if is_valid else "आंखें नहीं मिलीं। कृपया अपनी आंखें चौड़ी खोलें और पुनः प्रयास करें।",
619
- "eye_landmarks": {"left_eye": left_eye, "right_eye": right_eye}}
620
- except Exception:
621
- traceback.print_exc()
622
- raise HTTPException(status_code=500, detail="OpenCV fallback detector failed.")
623
-
624
- raise HTTPException(status_code=500, detail="Invalid detector configuration.")
625
- except HTTPException:
626
- raise
627
  except Exception as e:
628
- traceback.print_exc()
629
- return {"valid": False, "face_detected": False, "eye_openness_score": 0.0,
630
- "message_english": "Error processing image. Please try again.",
631
- "message_hindi": "छवि प्रोसेस करने में त्रुटि। कृपया पुनः प्रयास करें।",
632
- "error": str(e)}
633
 
634
  @app.post("/api/v1/upload")
635
  async def upload_images(
@@ -637,25 +544,21 @@ async def upload_images(
637
  face_image: UploadFile = File(...),
638
  eye_image: UploadFile = File(...)
639
  ):
640
- """
641
- Save images and enqueue background processing. VLM -> LLM runs inside process_screening.
642
- """
643
  try:
644
  screening_id = str(uuid.uuid4())
645
- now = datetime.utcnow().isoformat() + "Z"
646
- tmp_dir = "/tmp/elderly_healthwatch"
647
- os.makedirs(tmp_dir, exist_ok=True)
648
- face_path = os.path.join(tmp_dir, f"{screening_id}_face.jpg")
649
- eye_path = os.path.join(tmp_dir, f"{screening_id}_eye.jpg")
650
- face_bytes = await face_image.read()
651
- eye_bytes = await eye_image.read()
652
  with open(face_path, "wb") as f:
653
- f.write(face_bytes)
654
  with open(eye_path, "wb") as f:
655
- f.write(eye_bytes)
 
656
  screenings_db[screening_id] = {
657
  "id": screening_id,
658
- "timestamp": now,
659
  "face_image_path": face_path,
660
  "eye_image_path": eye_path,
661
  "status": "queued",
@@ -664,331 +567,123 @@ async def upload_images(
664
  "disease_predictions": [],
665
  "recommendations": {}
666
  }
 
667
  background_tasks.add_task(process_screening, screening_id)
 
668
  return {"screening_id": screening_id}
 
669
  except Exception as e:
670
- traceback.print_exc()
671
- raise HTTPException(status_code=500, detail=f"Failed to upload images: {e}")
672
 
673
  @app.post("/api/v1/analyze/{screening_id}")
674
  async def analyze_screening(screening_id: str, background_tasks: BackgroundTasks):
 
675
  if screening_id not in screenings_db:
676
  raise HTTPException(status_code=404, detail="Screening not found")
677
- if screenings_db[screening_id].get("status") == "processing":
 
678
  return {"message": "Already processing"}
 
679
  screenings_db[screening_id]["status"] = "queued"
680
  background_tasks.add_task(process_screening, screening_id)
 
681
  return {"message": "Analysis enqueued"}
682
 
683
  @app.get("/api/v1/status/{screening_id}")
684
  async def get_status(screening_id: str):
 
685
  if screening_id not in screenings_db:
686
  raise HTTPException(status_code=404, detail="Screening not found")
687
- status = screenings_db[screening_id].get("status", "unknown")
 
688
  progress = 50 if status == "processing" else (100 if status == "completed" else 0)
 
689
  return {"screening_id": screening_id, "status": status, "progress": progress}
690
 
691
  @app.get("/api/v1/results/{screening_id}")
692
  async def get_results(screening_id: str):
 
693
  if screening_id not in screenings_db:
694
  raise HTTPException(status_code=404, detail="Screening not found")
 
695
  return screenings_db[screening_id]
696
 
697
  @app.get("/api/v1/history/{user_id}")
698
  async def get_history(user_id: str):
 
699
  history = [s for s in screenings_db.values() if s.get("user_id") == user_id]
700
  return {"screenings": history}
701
 
702
- # -----------------------
703
- # Immediate VLM -> LLM routes (return vitals in one call)
704
- # -----------------------
705
  @app.post("/api/v1/get-vitals")
706
  async def get_vitals_from_upload(
707
  face_image: UploadFile = File(...),
708
  eye_image: UploadFile = File(...)
709
  ):
710
- """
711
- Run VLM -> LLM pipeline synchronously (but off the event loop) and return:
712
- { vlm_features, vlm_raw, structured_risk }
713
- """
714
  if not GRADIO_AVAILABLE:
715
- raise HTTPException(status_code=500, detail="VLM/LLM client not available in this deployment.")
716
-
717
- # save files to a temp directory
718
  try:
719
- tmp_dir = "/tmp/elderly_healthwatch"
720
- os.makedirs(tmp_dir, exist_ok=True)
721
  uid = str(uuid.uuid4())
722
- face_path = os.path.join(tmp_dir, f"{uid}_face.jpg")
723
- eye_path = os.path.join(tmp_dir, f"{uid}_eye.jpg")
724
- face_bytes = await face_image.read()
725
- eye_bytes = await eye_image.read()
726
  with open(face_path, "wb") as f:
727
- f.write(face_bytes)
728
  with open(eye_path, "wb") as f:
729
- f.write(eye_bytes)
730
- except Exception as e:
731
- logger.exception("Failed saving uploaded images")
732
- raise HTTPException(status_code=500, detail=f"Failed saving images: {e}")
733
-
734
- try:
735
- # Run VLM (off the event loop)
736
- vlm_features, vlm_raw = await asyncio.to_thread(run_vlm_and_get_features, face_path, eye_path)
737
-
738
- # Prefer sending raw vlm text to LLM (same behavior as process_screening)
739
  llm_input = vlm_raw if vlm_raw else (vlm_features if vlm_features else "{}")
740
-
741
- # Run LLM (off the event loop)
742
- structured_risk = await asyncio.to_thread(run_llm_on_vlm, llm_input)
743
-
744
- # Return merged result
745
  return {
746
  "vlm_features": vlm_features,
747
  "vlm_raw": vlm_raw,
748
  "structured_risk": structured_risk
749
  }
 
750
  except Exception as e:
751
- logger.exception("get_vitals_from_upload pipeline failed")
752
- raise HTTPException(status_code=500, detail=f"Pipeline failed: {e}")
753
 
754
  @app.post("/api/v1/get-vitals/{screening_id}")
755
  async def get_vitals_for_screening(screening_id: str):
756
- """
757
- Re-run VLM->LLM on images already stored for `screening_id` in screenings_db.
758
- Useful for re-processing or debugging.
759
- """
760
  if screening_id not in screenings_db:
761
  raise HTTPException(status_code=404, detail="Screening not found")
762
-
763
  entry = screenings_db[screening_id]
764
  face_path = entry.get("face_image_path")
765
  eye_path = entry.get("eye_image_path")
 
766
  if not (face_path and os.path.exists(face_path) and eye_path and os.path.exists(eye_path)):
767
- raise HTTPException(status_code=400, detail="Stored images missing for this screening")
768
-
769
  try:
770
- # Run VLM off the event loop
771
- vlm_features, vlm_raw = await asyncio.to_thread(run_vlm_and_get_features, face_path, eye_path)
772
-
773
  llm_input = vlm_raw if vlm_raw else (vlm_features if vlm_features else "{}")
774
- structured_risk = await asyncio.to_thread(run_llm_on_vlm, llm_input)
775
-
776
- # Optionally store this run's outputs back into the DB for inspection
777
- entry.setdefault("ai_results", {})
778
- entry["ai_results"].update({
779
  "vlm_features": vlm_features,
780
  "vlm_raw": vlm_raw,
781
  "structured_risk": structured_risk,
782
  "last_vitals_run": datetime.utcnow().isoformat() + "Z"
783
  })
784
-
785
  return {
786
  "screening_id": screening_id,
787
  "vlm_features": vlm_features,
788
  "vlm_raw": vlm_raw,
789
  "structured_risk": structured_risk
790
  }
 
791
  except Exception as e:
792
- logger.exception("get_vitals_for_screening pipeline failed")
793
- raise HTTPException(status_code=500, detail=f"Pipeline failed: {e}")
794
-
795
- # -----------------------
796
- # Main processing pipeline
797
- # -----------------------
798
- async def process_screening(screening_id: str):
799
- """
800
- Main pipeline:
801
- - load images
802
- - quick detector-based quality metrics
803
- - run VLM -> vlm_features (dict or None) + vlm_raw (string)
804
- - run LLM on vlm_raw (preferred) or vlm_features -> structured risk JSON
805
- - merge results into ai_results and finish
806
- """
807
- try:
808
- if screening_id not in screenings_db:
809
- logger.error("[process_screening] screening %s not found", screening_id)
810
- return
811
- screenings_db[screening_id]["status"] = "processing"
812
- logger.info("[process_screening] Starting %s", screening_id)
813
-
814
- entry = screenings_db[screening_id]
815
- face_path = entry.get("face_image_path")
816
- eye_path = entry.get("eye_image_path")
817
-
818
- if not (face_path and os.path.exists(face_path)):
819
- raise RuntimeError("Face image missing")
820
- if not (eye_path and os.path.exists(eye_path)):
821
- raise RuntimeError("Eye image missing")
822
-
823
- face_img = Image.open(face_path).convert("RGB")
824
- eye_img = Image.open(eye_path).convert("RGB")
825
-
826
- # Basic detection + quality metrics (facenet/mtcnn/opencv)
827
- face_detected = False
828
- face_confidence = 0.0
829
- left_eye_coord = right_eye_coord = None
830
-
831
- if mtcnn is not None and not isinstance(mtcnn, dict) and (_MTCNN_IMPL == "facenet_pytorch" or _MTCNN_IMPL == "mtcnn"):
832
- try:
833
- if _MTCNN_IMPL == "facenet_pytorch":
834
- boxes, probs, landmarks = mtcnn.detect(face_img, landmarks=True)
835
- if boxes is not None and len(boxes) > 0:
836
- face_detected = True
837
- face_confidence = float(probs[0]) if probs is not None else 0.0
838
- if landmarks is not None:
839
- lm = landmarks[0]
840
- if len(lm) >= 2:
841
- left_eye_coord = {"x": float(lm[0][0]), "y": float(lm[0][1])}
842
- right_eye_coord = {"x": float(lm[1][0]), "y": float(lm[1][1])}
843
- else:
844
- arr = np.asarray(face_img)
845
- detections = mtcnn.detect_faces(arr)
846
- if detections:
847
- face_detected = True
848
- face_confidence = float(detections[0].get("confidence", 0.0))
849
- k = detections[0].get("keypoints", {})
850
- left_eye_coord = k.get("left_eye")
851
- right_eye_coord = k.get("right_eye")
852
- except Exception:
853
- traceback.print_exc()
854
-
855
- if isinstance(mtcnn, dict) and mtcnn.get("impl") == "opencv":
856
- try:
857
- arr = np.asarray(face_img)
858
- gray = cv2.cvtColor(arr, cv2.COLOR_RGB2GRAY)
859
- face_cascade = mtcnn["face_cascade"]
860
- eye_cascade = mtcnn["eye_cascade"]
861
- faces = face_cascade.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=4, minSize=(60, 60))
862
- if len(faces) > 0:
863
- face_detected = True
864
- (x, y, w, h) = faces[0]
865
- face_confidence = min(1.0, (w*h) / (arr.shape[0]*arr.shape[1]) * 4.0)
866
- roi_gray = gray[y:y+h, x:x+w]
867
- eyes = eye_cascade.detectMultiScale(roi_gray, scaleFactor=1.1, minNeighbors=5, minSize=(20, 10))
868
- if len(eyes) >= 1:
869
- ex, ey, ew, eh = eyes[0]
870
- left_eye_coord = {"x": float(x + ex + ew/2), "y": float(y + ey + eh/2)}
871
- except Exception:
872
- traceback.print_exc()
873
-
874
- face_quality_score = 0.85 if face_detected and face_confidence > 0.6 else 0.45
875
- quality_metrics = {
876
- "face_detected": face_detected,
877
- "face_confidence": round(face_confidence, 3),
878
- "face_quality_score": round(face_quality_score, 2),
879
- "eye_coords": {"left_eye": left_eye_coord, "right_eye": right_eye_coord},
880
- "face_brightness": int(np.mean(np.asarray(face_img.convert("L")))),
881
- "face_blur_estimate": int(np.var(np.asarray(face_img.convert("L"))))
882
- }
883
- screenings_db[screening_id]["quality_metrics"] = quality_metrics
884
-
885
- # --------------------------
886
- # RUN VLM -> get vlm_features + vlm_raw
887
- # --------------------------
888
- vlm_features = None
889
- vlm_raw = None
890
- try:
891
- vlm_features, vlm_raw = run_vlm_and_get_features(face_path, eye_path)
892
- screenings_db[screening_id].setdefault("ai_results", {})
893
- screenings_db[screening_id]["ai_results"].update({
894
- "vlm_features": vlm_features,
895
- "vlm_raw": vlm_raw
896
- })
897
- except Exception as e:
898
- logger.exception("VLM feature extraction failed")
899
- screenings_db[screening_id].setdefault("ai_results", {})
900
- screenings_db[screening_id]["ai_results"].update({"vlm_error": str(e)})
901
- vlm_features = None
902
- vlm_raw = None
903
-
904
- # --------------------------
905
- # RUN LLM on vlm_raw (preferred) or vlm_features -> structured risk JSON
906
- # --------------------------
907
- structured_risk = None
908
- try:
909
- if vlm_raw:
910
- structured_risk = run_llm_on_vlm(vlm_raw)
911
- elif vlm_features:
912
- structured_risk = run_llm_on_vlm(vlm_features)
913
- else:
914
- # Fallback if VLM failed: produce conservative defaults
915
- structured_risk = {
916
- "risk_score": 0.0,
917
- "jaundice_probability": 0.0,
918
- "anemia_probability": 0.0,
919
- "hydration_issue_probability": 0.0,
920
- "neurological_issue_probability": 0.0,
921
- "summary": "",
922
- "recommendation": "",
923
- "confidence": 0.0
924
- }
925
- screenings_db[screening_id].setdefault("ai_results", {})
926
- screenings_db[screening_id]["ai_results"].update({"structured_risk": structured_risk})
927
- except Exception as e:
928
- logger.exception("LLM processing failed")
929
- screenings_db[screening_id].setdefault("ai_results", {})
930
- screenings_db[screening_id]["ai_results"].update({"llm_error": str(e)})
931
- structured_risk = {
932
- "risk_score": 0.0,
933
- "jaundice_probability": 0.0,
934
- "anemia_probability": 0.0,
935
- "hydration_issue_probability": 0.0,
936
- "neurological_issue_probability": 0.0,
937
- "summary": "",
938
- "recommendation": "",
939
- "confidence": 0.0
940
- }
941
-
942
- # Use structured_risk for summary recommendations & simple disease inference placeholders
943
- hem = screenings_db[screening_id]["ai_results"].get("medical_insights", {}).get("hemoglobin_estimate", None)
944
- bil = screenings_db[screening_id]["ai_results"].get("medical_insights", {}).get("bilirubin_estimate", None)
945
-
946
- # Keep older ai_results shape for backward compatibility (if you want)
947
- screenings_db[screening_id].setdefault("ai_results", {})
948
- screenings_db[screening_id]["ai_results"].update({
949
- "processing_time_ms": 1200
950
- })
951
-
952
- # disease_predictions & recommendations can be built from structured_risk if needed
953
- disease_predictions = [
954
- {
955
- "condition": "Anemia-like-signs", # internal tag (not surfaced in LLM summary)
956
- "risk_level": "Medium" if structured_risk.get("anemia_probability", 0.0) > 0.5 else "Low",
957
- "probability": structured_risk.get("anemia_probability", 0.0),
958
- "confidence": structured_risk.get("confidence", 0.0)
959
- },
960
- {
961
- "condition": "Jaundice-like-signs",
962
- "risk_level": "Medium" if structured_risk.get("jaundice_probability", 0.0) > 0.5 else "Low",
963
- "probability": structured_risk.get("jaundice_probability", 0.0),
964
- "confidence": structured_risk.get("confidence", 0.0)
965
- }
966
- ]
967
-
968
- recommendations = {
969
- "action_needed": "consult" if structured_risk.get("risk_score", 0.0) > 30.0 else "monitor",
970
- "message_english": structured_risk.get("recommendation", "") or f"Please follow up with a health professional if concerns persist.",
971
- "message_hindi": "" # could be auto-translated if desired
972
- }
973
-
974
- screenings_db[screening_id].update({
975
- "status": "completed",
976
- "disease_predictions": disease_predictions,
977
- "recommendations": recommendations
978
- })
979
-
980
- logger.info("[process_screening] Completed %s", screening_id)
981
- except Exception as e:
982
- traceback.print_exc()
983
- if screening_id in screenings_db:
984
- screenings_db[screening_id]["status"] = "failed"
985
- screenings_db[screening_id]["error"] = str(e)
986
- else:
987
- logger.error("[process_screening] Failed for unknown screening %s: %s", screening_id, str(e))
988
 
989
- # -----------------------
990
- # Run server (for local debugging)
991
- # -----------------------
992
  if __name__ == "__main__":
993
  import uvicorn
994
- uvicorn.run("app:app", host="0.0.0.0", port=7860, reload=False)
 
 
1
  """
2
+ Elderly HealthWatch AI Backend (FastAPI) - Refactored
3
+ Simplified architecture with same API routes for frontend compatibility.
 
 
 
 
 
 
 
 
 
 
 
 
4
  """
5
 
6
  import io
 
18
  from fastapi.middleware.cors import CORSMiddleware
19
  from PIL import Image
20
  import numpy as np
21
+ import cv2
22
 
 
23
  try:
24
+ from gradio_client import Client, handle_file
25
  GRADIO_AVAILABLE = True
26
  except Exception:
27
  GRADIO_AVAILABLE = False
28
 
29
+ # ============================================================================
30
+ # Configuration
31
+ # ============================================================================
32
  logging.basicConfig(level=logging.INFO)
33
  logger = logging.getLogger("elderly_healthwatch")
34
 
 
35
  GRADIO_VLM_SPACE = os.getenv("GRADIO_SPACE", "developer0hye/Qwen3-VL-8B-Instruct")
36
  LLM_GRADIO_SPACE = os.getenv("LLM_GRADIO_SPACE", "Tonic/med-gpt-oss-20b-demo")
37
  HF_TOKEN = os.getenv("HF_TOKEN", None)
38
 
 
39
  DEFAULT_VLM_PROMPT = (
40
  "From the provided face/eye images, compute the required screening features "
41
  "(pallor, sclera yellowness, redness, mobility metrics, quality checks) "
42
  "and output a clean JSON feature vector only."
43
  )
44
 
45
+ LLM_SYSTEM_PROMPT = (
46
+ "System: This assistant MUST ONLY OUTPUT a single valid JSON object as its response — "
47
+ "no prose, no explanations, no code fences, no annotations."
 
48
  )
49
+
50
+ LLM_DEVELOPER_PROMPT = (
51
+ "Developer: Output ONLY a single valid JSON object with keys: risk_score, "
52
+ "jaundice_probability, anemia_probability, hydration_issue_probability, "
53
+ "neurological_issue_probability, summary, recommendation, confidence. "
54
+ "Do NOT include any extra fields or natural language outside the JSON object."
 
55
  )
56
 
57
+ TMP_DIR = "/tmp/elderly_healthwatch"
58
+ os.makedirs(TMP_DIR, exist_ok=True)
 
 
 
 
 
 
59
 
60
+ # In-memory database
61
+ screenings_db: Dict[str, Dict[str, Any]] = {}
62
+
63
+ # ============================================================================
64
+ # Face Detection Setup
65
+ # ============================================================================
66
+ def setup_face_detector():
67
+ """Initialize face detector (MTCNN or OpenCV fallback)"""
68
+ # Try facenet-pytorch MTCNN
69
  try:
70
+ from facenet_pytorch import MTCNN
71
+ return MTCNN(keep_all=False, device="cpu"), "facenet_pytorch"
72
  except Exception:
73
+ pass
74
+
75
+ # Try classic MTCNN
 
 
 
 
 
 
 
 
 
 
 
76
  try:
77
+ from mtcnn import MTCNN
78
+ return MTCNN(), "mtcnn"
79
+ except Exception:
80
+ pass
81
+
82
+ # OpenCV Haar cascade fallback
83
+ try:
84
+ face_path = os.path.join(cv2.data.haarcascades, "haarcascade_frontalface_default.xml")
85
+ eye_path = os.path.join(cv2.data.haarcascades, "haarcascade_eye.xml")
86
+ if os.path.exists(face_path) and os.path.exists(eye_path):
87
  return {
88
  "impl": "opencv",
89
+ "face_cascade": cv2.CascadeClassifier(face_path),
90
+ "eye_cascade": cv2.CascadeClassifier(eye_path)
91
+ }, "opencv"
92
  except Exception:
93
  pass
94
+
95
+ return None, None
 
 
 
 
 
 
 
 
 
 
96
 
97
+ face_detector, detector_type = setup_face_detector()
 
98
 
99
+ # ============================================================================
100
+ # Utility Functions
101
+ # ============================================================================
102
  def load_image_from_bytes(bytes_data: bytes) -> Image.Image:
103
  return Image.open(io.BytesIO(bytes_data)).convert("RGB")
104
 
105
+ def normalize_probability(val: Optional[float]) -> float:
106
+ """Normalize probability to 0-1 range"""
107
+ if val is None:
 
 
 
108
  return 0.0
109
+ if val > 1.0 and val <= 100.0:
110
+ return max(0.0, min(1.0, val / 100.0))
111
+ if val > 100.0:
112
+ return 1.0
113
+ return max(0.0, min(1.0, val))
114
+
115
+ def normalize_risk_score(val: Optional[float]) -> float:
116
+ """Normalize risk score to 0-100 range"""
117
+ if val is None:
118
+ return 0.0
119
+ if val <= 1.0:
120
+ return round(max(0.0, min(100.0, val * 100.0)), 2)
121
+ return round(max(0.0, min(100.0, val)), 2)
122
+
123
+ # ============================================================================
124
+ # Face Detection Functions
125
+ # ============================================================================
126
+ def detect_face_and_eyes(pil_img: Image.Image) -> Dict[str, Any]:
127
+ """Detect face and eyes, return quality metrics"""
128
+ if face_detector is None:
129
+ return {
130
+ "face_detected": False,
131
+ "face_confidence": 0.0,
132
+ "eye_openness_score": 0.0,
133
+ "left_eye": None,
134
+ "right_eye": None
135
+ }
136
+
137
+ img_arr = np.asarray(pil_img)
138
+
139
+ # Facenet-pytorch MTCNN
140
+ if detector_type == "facenet_pytorch":
141
+ try:
142
+ boxes, probs, landmarks = face_detector.detect(pil_img, landmarks=True)
143
+ if boxes is None or len(boxes) == 0:
144
+ return {"face_detected": False, "face_confidence": 0.0, "eye_openness_score": 0.0,
145
+ "left_eye": None, "right_eye": None}
146
+
147
+ confidence = float(probs[0]) if probs is not None else 0.0
148
+ lm = landmarks[0] if landmarks is not None else None
149
+ left_eye = right_eye = None
150
+
151
+ if lm is not None and len(lm) >= 2:
152
+ left_eye = {"x": float(lm[0][0]), "y": float(lm[0][1])}
153
+ right_eye = {"x": float(lm[1][0]), "y": float(lm[1][1])}
154
+
155
+ return {
156
+ "face_detected": True,
157
+ "face_confidence": confidence,
158
+ "eye_openness_score": min(max(confidence * 1.15, 0.0), 1.0),
159
+ "left_eye": left_eye,
160
+ "right_eye": right_eye
161
+ }
162
+ except Exception as e:
163
+ logger.exception("Facenet MTCNN detection failed")
164
+ return {"face_detected": False, "face_confidence": 0.0, "eye_openness_score": 0.0,
165
+ "left_eye": None, "right_eye": None}
166
+
167
+ # Classic MTCNN
168
+ elif detector_type == "mtcnn":
169
+ try:
170
+ detections = face_detector.detect_faces(img_arr)
171
+ if not detections:
172
+ return {"face_detected": False, "face_confidence": 0.0, "eye_openness_score": 0.0,
173
+ "left_eye": None, "right_eye": None}
174
+
175
+ face = detections[0]
176
+ keypoints = face.get("keypoints", {})
177
+ confidence = float(face.get("confidence", 0.0))
178
+
179
+ return {
180
+ "face_detected": True,
181
+ "face_confidence": confidence,
182
+ "eye_openness_score": min(max(confidence * 1.15, 0.0), 1.0),
183
+ "left_eye": keypoints.get("left_eye"),
184
+ "right_eye": keypoints.get("right_eye")
185
+ }
186
+ except Exception as e:
187
+ logger.exception("Classic MTCNN detection failed")
188
+ return {"face_detected": False, "face_confidence": 0.0, "eye_openness_score": 0.0,
189
+ "left_eye": None, "right_eye": None}
190
+
191
+ # OpenCV fallback
192
+ elif detector_type == "opencv":
193
+ try:
194
+ gray = cv2.cvtColor(img_arr, cv2.COLOR_RGB2GRAY)
195
+ faces = face_detector["face_cascade"].detectMultiScale(
196
+ gray, scaleFactor=1.1, minNeighbors=4, minSize=(60, 60)
197
+ )
198
+
199
+ if len(faces) == 0:
200
+ return {"face_detected": False, "face_confidence": 0.0, "eye_openness_score": 0.0,
201
+ "left_eye": None, "right_eye": None}
202
+
203
+ (x, y, w, h) = faces[0]
204
+ roi_gray = gray[y:y+h, x:x+w]
205
+ eyes = face_detector["eye_cascade"].detectMultiScale(
206
+ roi_gray, scaleFactor=1.1, minNeighbors=5, minSize=(20, 10)
207
+ )
208
+
209
+ eye_openness = 1.0 if len(eyes) >= 1 else 0.0
210
+ left_eye = None
211
+
212
+ if len(eyes) >= 1:
213
+ ex, ey, ew, eh = eyes[0]
214
+ left_eye = {"x": float(x + ex + ew/2), "y": float(y + ey + eh/2)}
215
+
216
+ confidence = min(1.0, (w*h) / (img_arr.shape[0]*img_arr.shape[1]) * 4.0)
217
+
218
+ return {
219
+ "face_detected": True,
220
+ "face_confidence": confidence,
221
+ "eye_openness_score": eye_openness,
222
+ "left_eye": left_eye,
223
+ "right_eye": None
224
+ }
225
+ except Exception as e:
226
+ logger.exception("OpenCV detection failed")
227
+ return {"face_detected": False, "face_confidence": 0.0, "eye_openness_score": 0.0,
228
+ "left_eye": None, "right_eye": None}
229
+
230
+ return {"face_detected": False, "face_confidence": 0.0, "eye_openness_score": 0.0,
231
+ "left_eye": None, "right_eye": None}
232
+
233
+ # ============================================================================
234
+ # JSON Extraction from LLM Output
235
+ # ============================================================================
236
+ def extract_json_from_llm_output(raw_text: str) -> Dict[str, Any]:
237
+ """Extract and normalize JSON from LLM output using regex"""
238
  match = re.search(r"\{[\s\S]*\}", raw_text)
239
  if not match:
240
  raise ValueError("No JSON-like block found in LLM output")
241
+
242
  block = match.group(0)
243
+
244
+ def find_number(key: str) -> Optional[float]:
 
 
 
 
 
 
245
  patterns = [
246
+ rf'"{key}"\s*:\s*["\']?\s*([-+]?\d+(\.\d+)?)\s*%?\s*["\']?',
247
  rf"'{key}'\s*:\s*['\"]?\s*([-+]?\d+(\.\d+)?)\s*%?\s*['\"]?",
248
+ rf'\b{key}\b\s*:\s*["\']?\s*([-+]?\d+(\.\d+)?)\s*%?\s*["\']?',
 
 
249
  ]
250
  for pat in patterns:
251
  m = re.search(pat, block, flags=re.IGNORECASE)
252
+ if m and m.group(1):
253
+ try:
254
+ return float(m.group(1).replace("%", "").strip())
255
+ except Exception:
256
+ pass
 
 
 
 
 
 
 
 
 
 
257
  return None
258
+
259
+ def find_text(key: str) -> str:
 
260
  m = re.search(rf'"{key}"\s*:\s*"([^"]*)"', block, flags=re.IGNORECASE)
261
  if m:
262
  return m.group(1).strip()
263
  m = re.search(rf"'{key}'\s*:\s*'([^']*)'", block, flags=re.IGNORECASE)
264
  if m:
265
  return m.group(1).strip()
 
266
  m = re.search(rf'\b{key}\b\s*:\s*([^\n,}}]+)', block, flags=re.IGNORECASE)
267
  if m:
268
  return m.group(1).strip().strip('",')
269
  return ""
270
+
271
+ return {
272
+ "risk_score": normalize_risk_score(find_number("risk_score")),
273
+ "jaundice_probability": round(normalize_probability(find_number("jaundice_probability")), 4),
274
+ "anemia_probability": round(normalize_probability(find_number("anemia_probability")), 4),
275
+ "hydration_issue_probability": round(normalize_probability(find_number("hydration_issue_probability")), 4),
276
+ "neurological_issue_probability": round(normalize_probability(find_number("neurological_issue_probability")), 4),
277
+ "confidence": round(normalize_probability(find_number("confidence")), 4),
278
+ "summary": find_text("summary"),
279
+ "recommendation": find_text("recommendation")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
280
  }
 
281
 
282
+ # ============================================================================
283
+ # VLM & LLM Integration
284
+ # ============================================================================
285
+ def get_gradio_client(space: str) -> Client:
286
+ """Get Gradio client with optional auth"""
287
  if not GRADIO_AVAILABLE:
288
+ raise RuntimeError("gradio_client not installed")
289
+ return Client(space, hf_token=HF_TOKEN) if HF_TOKEN else Client(space)
290
+
291
+ def call_vlm(face_path: str, eye_path: str, prompt: Optional[str] = None) -> Tuple[Optional[Dict], str]:
292
+ """Call VLM and return (parsed_features, raw_text)"""
 
 
 
 
 
 
 
 
293
  prompt = prompt or DEFAULT_VLM_PROMPT
294
+
295
  if not os.path.exists(face_path) or not os.path.exists(eye_path):
296
+ raise FileNotFoundError("Face or eye image path missing")
297
+
298
+ client = get_gradio_client(GRADIO_VLM_SPACE)
 
 
299
  message = {"text": prompt, "files": [handle_file(face_path), handle_file(eye_path)]}
300
+
301
  try:
302
+ logger.info("Calling VLM Space: %s", GRADIO_VLM_SPACE)
303
  result = client.predict(message=message, history=[], api_name="/chat_fn")
304
  except Exception as e:
305
  logger.exception("VLM call failed")
306
  raise RuntimeError(f"VLM call failed: {e}")
307
+
 
 
 
308
  # Normalize result
309
  if isinstance(result, (list, tuple)):
310
  out = result[0]
 
312
  out = result
313
  else:
314
  out = {"text": str(result)}
315
+
316
+ text_out = out.get("text") or out.get("output") or json.dumps(out)
317
+
318
+ # Try to parse JSON
319
+ parsed = None
 
 
 
 
 
320
  try:
321
+ parsed = json.loads(text_out)
322
+ if not isinstance(parsed, dict):
323
+ parsed = None
324
  except Exception:
325
+ # Try to extract JSON from text
326
  try:
327
+ first = text_out.find("{")
328
+ last = text_out.rfind("}")
 
329
  if first != -1 and last != -1 and last > first:
330
+ parsed = json.loads(text_out[first:last+1])
331
+ if not isinstance(parsed, dict):
332
+ parsed = None
 
 
 
333
  except Exception:
334
+ parsed = None
335
+
336
+ return parsed, text_out
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
337
 
338
+ def call_llm(vlm_output: Any) -> Dict[str, Any]:
339
+ """Call LLM with VLM output and return structured risk assessment"""
340
+ if not GRADIO_AVAILABLE:
341
+ raise RuntimeError("gradio_client not installed")
342
+
343
+ client = get_gradio_client(LLM_GRADIO_SPACE)
344
+
345
+ # Prepare input
346
+ vlm_text = vlm_output if isinstance(vlm_output, str) else json.dumps(vlm_output, default=str)
347
+
 
 
348
  instruction = (
349
+ "\n\nSTRICT INSTRUCTIONS:\n"
350
+ "1) OUTPUT ONLY a single valid JSON object — no prose, no code fences.\n"
351
+ "2) Include keys: risk_score, jaundice_probability, anemia_probability, "
352
  "hydration_issue_probability, neurological_issue_probability, summary, recommendation, confidence.\n"
353
+ "3) Use numeric values for probabilities (0-1) and risk_score (0-100).\n"
354
+ "4) Use neutral wording in summary/recommendation.\n\n"
355
+ "VLM Output:\n" + vlm_text + "\n"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
356
  )
357
+
358
+ # Call with safe defaults
359
+ try:
360
+ logger.info("Calling LLM Space: %s", LLM_GRADIO_SPACE)
361
+ result = client.predict(
362
+ input_data=instruction,
363
+ max_new_tokens=1024.0,
364
+ model_identity=os.getenv("LLM_MODEL_IDENTITY", "GPT-Tonic"),
365
+ system_prompt=LLM_SYSTEM_PROMPT,
366
+ developer_prompt=LLM_DEVELOPER_PROMPT,
367
+ reasoning_effort="medium",
368
+ temperature=0.2,
369
+ top_p=0.9,
370
+ top_k=50,
371
+ repetition_penalty=1.0,
372
+ api_name="/chat"
373
+ )
374
+
375
+ text_out = json.dumps(result) if isinstance(result, (dict, list)) else str(result)
376
+ logger.info("LLM raw output:\n%s", text_out)
377
+
378
+ parsed = extract_json_from_llm_output(text_out)
379
+ logger.info("LLM parsed JSON:\n%s", json.dumps(parsed, indent=2))
380
+
381
+ return parsed
382
+
383
+ except Exception as e:
384
+ logger.exception("LLM call failed")
385
+ raise RuntimeError(f"LLM call failed: {e}")
386
+
387
+ # ============================================================================
388
+ # Background Processing
389
+ # ============================================================================
390
+ async def process_screening(screening_id: str):
391
+ """Main processing pipeline"""
392
+ try:
393
+ if screening_id not in screenings_db:
394
+ logger.error("Screening %s not found", screening_id)
395
+ return
396
+
397
+ screenings_db[screening_id]["status"] = "processing"
398
+ logger.info("Starting processing for %s", screening_id)
399
+
400
+ entry = screenings_db[screening_id]
401
+ face_path = entry["face_image_path"]
402
+ eye_path = entry["eye_image_path"]
403
+
404
+ # Load images and get quality metrics
405
+ face_img = Image.open(face_path).convert("RGB")
406
+ detection_result = detect_face_and_eyes(face_img)
407
+
408
+ quality_metrics = {
409
+ "face_detected": detection_result["face_detected"],
410
+ "face_confidence": round(detection_result["face_confidence"], 3),
411
+ "face_quality_score": 0.85 if detection_result["face_detected"] else 0.45,
412
+ "eye_coords": {
413
+ "left_eye": detection_result["left_eye"],
414
+ "right_eye": detection_result["right_eye"]
415
+ },
416
+ "face_brightness": int(np.mean(np.asarray(face_img.convert("L")))),
417
+ "face_blur_estimate": int(np.var(np.asarray(face_img.convert("L"))))
418
+ }
419
+ screenings_db[screening_id]["quality_metrics"] = quality_metrics
420
+
421
+ # Call VLM
422
+ vlm_features, vlm_raw = await asyncio.to_thread(call_vlm, face_path, eye_path)
423
+
424
+ # Call LLM
425
+ llm_input = vlm_raw if vlm_raw else (vlm_features if vlm_features else "{}")
426
+ structured_risk = await asyncio.to_thread(call_llm, llm_input)
427
+
428
+ # Store results
429
+ screenings_db[screening_id]["ai_results"] = {
430
+ "vlm_features": vlm_features,
431
+ "vlm_raw": vlm_raw,
432
+ "structured_risk": structured_risk,
433
+ "processing_time_ms": 1200
434
+ }
435
+
436
+ # Build disease predictions
437
+ disease_predictions = [
438
+ {
439
+ "condition": "Anemia-like-signs",
440
+ "risk_level": "Medium" if structured_risk["anemia_probability"] > 0.5 else "Low",
441
+ "probability": structured_risk["anemia_probability"],
442
+ "confidence": structured_risk["confidence"]
443
+ },
444
+ {
445
+ "condition": "Jaundice-like-signs",
446
+ "risk_level": "Medium" if structured_risk["jaundice_probability"] > 0.5 else "Low",
447
+ "probability": structured_risk["jaundice_probability"],
448
+ "confidence": structured_risk["confidence"]
449
+ }
450
+ ]
451
+
452
+ recommendations = {
453
+ "action_needed": "consult" if structured_risk["risk_score"] > 30.0 else "monitor",
454
+ "message_english": structured_risk["recommendation"] or "Please follow up with a health professional if concerns persist.",
455
+ "message_hindi": ""
456
+ }
457
+
458
+ screenings_db[screening_id].update({
459
+ "status": "completed",
460
+ "disease_predictions": disease_predictions,
461
+ "recommendations": recommendations
462
+ })
463
+
464
+ logger.info("Completed processing for %s", screening_id)
465
+
466
+ except Exception as e:
467
+ logger.exception("Processing failed for %s", screening_id)
468
+ if screening_id in screenings_db:
469
+ screenings_db[screening_id]["status"] = "failed"
470
+ screenings_db[screening_id]["error"] = str(e)
471
+
472
+ # ============================================================================
473
+ # FastAPI App & Routes
474
+ # ============================================================================
475
+ app = FastAPI(title="Elderly HealthWatch AI Backend")
476
+ app.add_middleware(
477
+ CORSMiddleware,
478
+ allow_origins=["*"],
479
+ allow_credentials=True,
480
+ allow_methods=["*"],
481
+ allow_headers=["*"],
482
+ )
483
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
484
  @app.get("/")
485
  async def read_root():
486
  return {"message": "Elderly HealthWatch AI Backend"}
487
 
488
  @app.get("/health")
489
  async def health_check():
 
 
 
 
 
 
 
490
  return {
491
  "status": "healthy",
492
+ "detector": detector_type or "none",
493
  "vlm_available": GRADIO_AVAILABLE,
494
  "vlm_space": GRADIO_VLM_SPACE,
495
  "llm_space": LLM_GRADIO_SPACE
 
497
 
498
  @app.post("/api/v1/validate-eye-photo")
499
  async def validate_eye_photo(image: UploadFile = File(...)):
500
+ """Validate eye photo quality"""
501
+ if face_detector is None:
502
+ raise HTTPException(status_code=500, detail="No face detector available")
503
+
 
 
 
504
  try:
505
  content = await image.read()
506
  if not content:
507
+ raise HTTPException(status_code=400, detail="Empty file")
508
+
509
  pil_img = load_image_from_bytes(content)
510
+ result = detect_face_and_eyes(pil_img)
511
+
512
+ if not result["face_detected"]:
513
+ return {
514
+ "valid": False,
515
+ "face_detected": False,
516
+ "eye_openness_score": 0.0,
517
+ "message_english": "No face detected. Please ensure your face is clearly visible.",
518
+ "message_hindi": "कोई चेहरा नहीं मिला। कृपया सुनिश्चित करें कि आपका चेहरा स्पष्ट रूप से दिखाई दे।"
519
+ }
520
+
521
+ is_valid = result["eye_openness_score"] >= 0.3
522
+
523
+ return {
524
+ "valid": is_valid,
525
+ "face_detected": True,
526
+ "eye_openness_score": round(result["eye_openness_score"], 2),
527
+ "message_english": "Photo looks good! Eyes are properly open." if is_valid
528
+ else "Eyes appear closed. Please open your eyes wide and try again.",
529
+ "message_hindi": "फोटो अच्छी है! आंखें ठीक से खुली हैं।" if is_valid
530
+ else "आंखें बंद दिखाई दे रही हैं। कृपया अपनी आंखें चौड़ी खोलें।",
531
+ "eye_landmarks": {
532
+ "left_eye": result["left_eye"],
533
+ "right_eye": result["right_eye"]
534
+ }
535
+ }
536
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
537
  except Exception as e:
538
+ logger.exception("Validation failed")
539
+ raise HTTPException(status_code=500, detail=str(e))
 
 
 
540
 
541
  @app.post("/api/v1/upload")
542
  async def upload_images(
 
544
  face_image: UploadFile = File(...),
545
  eye_image: UploadFile = File(...)
546
  ):
547
+ """Upload images and start background processing"""
 
 
548
  try:
549
  screening_id = str(uuid.uuid4())
550
+
551
+ face_path = os.path.join(TMP_DIR, f"{screening_id}_face.jpg")
552
+ eye_path = os.path.join(TMP_DIR, f"{screening_id}_eye.jpg")
553
+
 
 
 
554
  with open(face_path, "wb") as f:
555
+ f.write(await face_image.read())
556
  with open(eye_path, "wb") as f:
557
+ f.write(await eye_image.read())
558
+
559
  screenings_db[screening_id] = {
560
  "id": screening_id,
561
+ "timestamp": datetime.utcnow().isoformat() + "Z",
562
  "face_image_path": face_path,
563
  "eye_image_path": eye_path,
564
  "status": "queued",
 
567
  "disease_predictions": [],
568
  "recommendations": {}
569
  }
570
+
571
  background_tasks.add_task(process_screening, screening_id)
572
+
573
  return {"screening_id": screening_id}
574
+
575
  except Exception as e:
576
+ logger.exception("Upload failed")
577
+ raise HTTPException(status_code=500, detail=str(e))
578
 
579
  @app.post("/api/v1/analyze/{screening_id}")
580
  async def analyze_screening(screening_id: str, background_tasks: BackgroundTasks):
581
+ """Re-analyze existing screening"""
582
  if screening_id not in screenings_db:
583
  raise HTTPException(status_code=404, detail="Screening not found")
584
+
585
+ if screenings_db[screening_id]["status"] == "processing":
586
  return {"message": "Already processing"}
587
+
588
  screenings_db[screening_id]["status"] = "queued"
589
  background_tasks.add_task(process_screening, screening_id)
590
+
591
  return {"message": "Analysis enqueued"}
592
 
593
  @app.get("/api/v1/status/{screening_id}")
594
  async def get_status(screening_id: str):
595
+ """Get processing status"""
596
  if screening_id not in screenings_db:
597
  raise HTTPException(status_code=404, detail="Screening not found")
598
+
599
+ status = screenings_db[screening_id]["status"]
600
  progress = 50 if status == "processing" else (100 if status == "completed" else 0)
601
+
602
  return {"screening_id": screening_id, "status": status, "progress": progress}
603
 
604
  @app.get("/api/v1/results/{screening_id}")
605
  async def get_results(screening_id: str):
606
+ """Get screening results"""
607
  if screening_id not in screenings_db:
608
  raise HTTPException(status_code=404, detail="Screening not found")
609
+
610
  return screenings_db[screening_id]
611
 
612
  @app.get("/api/v1/history/{user_id}")
613
  async def get_history(user_id: str):
614
+ """Get user screening history"""
615
  history = [s for s in screenings_db.values() if s.get("user_id") == user_id]
616
  return {"screenings": history}
617
 
 
 
 
618
  @app.post("/api/v1/get-vitals")
619
  async def get_vitals_from_upload(
620
  face_image: UploadFile = File(...),
621
  eye_image: UploadFile = File(...)
622
  ):
623
+ """Synchronous VLM + LLM pipeline"""
 
 
 
624
  if not GRADIO_AVAILABLE:
625
+ raise HTTPException(status_code=500, detail="VLM/LLM not available")
626
+
 
627
  try:
 
 
628
  uid = str(uuid.uuid4())
629
+ face_path = os.path.join(TMP_DIR, f"{uid}_face.jpg")
630
+ eye_path = os.path.join(TMP_DIR, f"{uid}_eye.jpg")
631
+
 
632
  with open(face_path, "wb") as f:
633
+ f.write(await face_image.read())
634
  with open(eye_path, "wb") as f:
635
+ f.write(await eye_image.read())
636
+
637
+ vlm_features, vlm_raw = await asyncio.to_thread(call_vlm, face_path, eye_path)
 
 
 
 
 
 
 
638
  llm_input = vlm_raw if vlm_raw else (vlm_features if vlm_features else "{}")
639
+ structured_risk = await asyncio.to_thread(call_llm, llm_input)
640
+
 
 
 
641
  return {
642
  "vlm_features": vlm_features,
643
  "vlm_raw": vlm_raw,
644
  "structured_risk": structured_risk
645
  }
646
+
647
  except Exception as e:
648
+ logger.exception("Get vitals failed")
649
+ raise HTTPException(status_code=500, detail=str(e))
650
 
651
  @app.post("/api/v1/get-vitals/{screening_id}")
652
  async def get_vitals_for_screening(screening_id: str):
653
+ """Re-run VLM + LLM on existing screening"""
 
 
 
654
  if screening_id not in screenings_db:
655
  raise HTTPException(status_code=404, detail="Screening not found")
656
+
657
  entry = screenings_db[screening_id]
658
  face_path = entry.get("face_image_path")
659
  eye_path = entry.get("eye_image_path")
660
+
661
  if not (face_path and os.path.exists(face_path) and eye_path and os.path.exists(eye_path)):
662
+ raise HTTPException(status_code=400, detail="Images missing")
663
+
664
  try:
665
+ vlm_features, vlm_raw = await asyncio.to_thread(call_vlm, face_path, eye_path)
 
 
666
  llm_input = vlm_raw if vlm_raw else (vlm_features if vlm_features else "{}")
667
+ structured_risk = await asyncio.to_thread(call_llm, llm_input)
668
+
669
+ entry.setdefault("ai_results", {}).update({
 
 
670
  "vlm_features": vlm_features,
671
  "vlm_raw": vlm_raw,
672
  "structured_risk": structured_risk,
673
  "last_vitals_run": datetime.utcnow().isoformat() + "Z"
674
  })
675
+
676
  return {
677
  "screening_id": screening_id,
678
  "vlm_features": vlm_features,
679
  "vlm_raw": vlm_raw,
680
  "structured_risk": structured_risk
681
  }
682
+
683
  except Exception as e:
684
+ logger.exception("Get vitals for screening failed")
685
+ raise HTTPException(status_code=500, detail=str(e))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
686
 
 
 
 
687
  if __name__ == "__main__":
688
  import uvicorn
689
+ uvicorn.run("app:app", host="0.0.0.0", port=7860, reload=