Update app.py
Browse files
app.py
CHANGED
|
@@ -4,6 +4,7 @@ Elderly HealthWatch AI Backend (FastAPI)
|
|
| 4 |
This variant uses:
|
| 5 |
- facenet-pytorch or mtcnn if available
|
| 6 |
- otherwise falls back to OpenCV Haar cascades (fast, CPU-only, lightweight)
|
|
|
|
| 7 |
"""
|
| 8 |
|
| 9 |
import io
|
|
@@ -18,6 +19,28 @@ import numpy as np
|
|
| 18 |
import os
|
| 19 |
import traceback
|
| 20 |
import cv2 # opencv-python-headless expected installed
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
# Attempt to import facenet-pytorch MTCNN first (recommended)
|
| 23 |
try:
|
|
@@ -102,6 +125,92 @@ def estimate_eye_openness_from_detection(confidence: float) -> float:
|
|
| 102 |
except Exception:
|
| 103 |
return 0.0
|
| 104 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
@app.get("/")
|
| 106 |
async def read_root():
|
| 107 |
return {"message": "Elderly HealthWatch AI Backend"}
|
|
@@ -115,7 +224,7 @@ async def health_check():
|
|
| 115 |
impl = "opencv_haar_fallback"
|
| 116 |
else:
|
| 117 |
impl = _MTCNN_IMPL
|
| 118 |
-
return {"status": "healthy", "detector": impl}
|
| 119 |
|
| 120 |
@app.post("/api/v1/validate-eye-photo")
|
| 121 |
async def validate_eye_photo(image: UploadFile = File(...)):
|
|
@@ -398,7 +507,23 @@ async def process_screening(screening_id: str):
|
|
| 398 |
}
|
| 399 |
screenings_db[screening_id]["quality_metrics"] = quality_metrics
|
| 400 |
|
| 401 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 402 |
await asyncio.sleep(1)
|
| 403 |
vlm_face_desc = "Patient appears to have normal facial tone; no severe jaundice visible."
|
| 404 |
vlm_eye_desc = "Sclera shows mild yellowing."
|
|
@@ -427,7 +552,9 @@ async def process_screening(screening_id: str):
|
|
| 427 |
"medical_insights": medical_insights,
|
| 428 |
"processing_time_ms": 1200
|
| 429 |
}
|
| 430 |
-
|
|
|
|
|
|
|
| 431 |
|
| 432 |
disease_predictions = [
|
| 433 |
{
|
|
|
|
| 4 |
This variant uses:
|
| 5 |
- facenet-pytorch or mtcnn if available
|
| 6 |
- otherwise falls back to OpenCV Haar cascades (fast, CPU-only, lightweight)
|
| 7 |
+
- integrates a remote VLM via gradio_client to get JSON feature vectors
|
| 8 |
"""
|
| 9 |
|
| 10 |
import io
|
|
|
|
| 19 |
import os
|
| 20 |
import traceback
|
| 21 |
import cv2 # opencv-python-headless expected installed
|
| 22 |
+
import json
|
| 23 |
+
import logging
|
| 24 |
+
|
| 25 |
+
# Optional gradio client import (for VLM)
|
| 26 |
+
try:
|
| 27 |
+
from gradio_client import Client, handle_file
|
| 28 |
+
GRADIO_AVAILABLE = True
|
| 29 |
+
except Exception:
|
| 30 |
+
GRADIO_AVAILABLE = False
|
| 31 |
+
|
| 32 |
+
# Configure logging
|
| 33 |
+
logging.basicConfig(level=logging.INFO)
|
| 34 |
+
|
| 35 |
+
# Configuration for remote VLM (change to your target Space)
|
| 36 |
+
GRADIO_SPACE = os.getenv("GRADIO_SPACE", "developer0hye/Qwen3-VL-8B-Instruct")
|
| 37 |
+
# If your space is private, set HF_TOKEN as a secret / env var in Spaces
|
| 38 |
+
HF_TOKEN = os.getenv("HF_TOKEN", None)
|
| 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 |
# Attempt to import facenet-pytorch MTCNN first (recommended)
|
| 46 |
try:
|
|
|
|
| 125 |
except Exception:
|
| 126 |
return 0.0
|
| 127 |
|
| 128 |
+
# --------------------------
|
| 129 |
+
# VLM client helper
|
| 130 |
+
# --------------------------
|
| 131 |
+
def get_gradio_client():
|
| 132 |
+
"""Return a configured gradio Client or raise if not available."""
|
| 133 |
+
if not GRADIO_AVAILABLE:
|
| 134 |
+
raise RuntimeError("gradio_client not installed in this environment.")
|
| 135 |
+
if HF_TOKEN:
|
| 136 |
+
return Client(GRADIO_SPACE, hf_token=HF_TOKEN)
|
| 137 |
+
return Client(GRADIO_SPACE)
|
| 138 |
+
|
| 139 |
+
def run_vlm_and_get_features(face_path: str, eye_path: str, prompt: Optional[str] = None) -> Dict[str, Any]:
|
| 140 |
+
"""
|
| 141 |
+
Synchronous call to the remote VLM (gradio / chat_fn).
|
| 142 |
+
Expects the VLM to return a JSON string only (we parse it).
|
| 143 |
+
On success returns a dict (parsed JSON).
|
| 144 |
+
On failure raises RuntimeError or ValueError.
|
| 145 |
+
"""
|
| 146 |
+
prompt = prompt or DEFAULT_VLM_PROMPT
|
| 147 |
+
|
| 148 |
+
if not os.path.exists(face_path) or not os.path.exists(eye_path):
|
| 149 |
+
raise FileNotFoundError("Face or eye image path missing for VLM call.")
|
| 150 |
+
|
| 151 |
+
if not GRADIO_AVAILABLE:
|
| 152 |
+
raise RuntimeError("gradio_client is not available in this environment.")
|
| 153 |
+
|
| 154 |
+
client = get_gradio_client()
|
| 155 |
+
|
| 156 |
+
message = {
|
| 157 |
+
"text": prompt,
|
| 158 |
+
"files": [handle_file(face_path), handle_file(eye_path)]
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
# Call the remote API
|
| 162 |
+
try:
|
| 163 |
+
logging.info("Calling remote VLM at %s", GRADIO_SPACE)
|
| 164 |
+
# result is typically a tuple: (output_dict, new_history)
|
| 165 |
+
result = client.predict(message=message, history=[], api_name="/chat_fn")
|
| 166 |
+
except Exception as e:
|
| 167 |
+
logging.exception("VLM call failed")
|
| 168 |
+
raise RuntimeError(f"VLM call failed: {e}")
|
| 169 |
+
|
| 170 |
+
# Extract text output
|
| 171 |
+
if not result or not isinstance(result, (list, tuple)):
|
| 172 |
+
# some spaces return just a dict too
|
| 173 |
+
if isinstance(result, dict):
|
| 174 |
+
out = result
|
| 175 |
+
else:
|
| 176 |
+
raise RuntimeError("Unexpected VLM response shape")
|
| 177 |
+
else:
|
| 178 |
+
out = result[0]
|
| 179 |
+
|
| 180 |
+
if not isinstance(out, dict):
|
| 181 |
+
raise RuntimeError("Unexpected VLM output format (expected dict with 'text' key)")
|
| 182 |
+
|
| 183 |
+
text_out = out.get("text") or out.get("output") or None
|
| 184 |
+
if not text_out:
|
| 185 |
+
raise RuntimeError("VLM returned empty text output")
|
| 186 |
+
|
| 187 |
+
# The model was instructed to return JSON only. Try to parse it.
|
| 188 |
+
try:
|
| 189 |
+
features = json.loads(text_out)
|
| 190 |
+
except Exception:
|
| 191 |
+
# attempt a forgiving extraction: find the first { ... } block
|
| 192 |
+
try:
|
| 193 |
+
s = text_out
|
| 194 |
+
first = s.find("{")
|
| 195 |
+
last = s.rfind("}")
|
| 196 |
+
if first != -1 and last != -1 and last > first:
|
| 197 |
+
maybe = s[first:last+1]
|
| 198 |
+
features = json.loads(maybe)
|
| 199 |
+
else:
|
| 200 |
+
raise
|
| 201 |
+
except Exception as e:
|
| 202 |
+
logging.exception("Failed to parse JSON from VLM output")
|
| 203 |
+
raise ValueError(f"Failed to parse JSON from VLM output: {e}\nRaw output: {text_out}")
|
| 204 |
+
|
| 205 |
+
if not isinstance(features, dict):
|
| 206 |
+
raise ValueError("Parsed VLM output is not a JSON object/dict")
|
| 207 |
+
|
| 208 |
+
return features
|
| 209 |
+
|
| 210 |
+
# --------------------------
|
| 211 |
+
# End VLM helper
|
| 212 |
+
# --------------------------
|
| 213 |
+
|
| 214 |
@app.get("/")
|
| 215 |
async def read_root():
|
| 216 |
return {"message": "Elderly HealthWatch AI Backend"}
|
|
|
|
| 224 |
impl = "opencv_haar_fallback"
|
| 225 |
else:
|
| 226 |
impl = _MTCNN_IMPL
|
| 227 |
+
return {"status": "healthy", "detector": impl, "vlm_available": GRADIO_AVAILABLE}
|
| 228 |
|
| 229 |
@app.post("/api/v1/validate-eye-photo")
|
| 230 |
async def validate_eye_photo(image: UploadFile = File(...)):
|
|
|
|
| 507 |
}
|
| 508 |
screenings_db[screening_id]["quality_metrics"] = quality_metrics
|
| 509 |
|
| 510 |
+
# Attempt VLM call to compute multimodal features (pallor, sclera yellowness, etc.)
|
| 511 |
+
try:
|
| 512 |
+
vlm_features = run_vlm_and_get_features(face_path, eye_path)
|
| 513 |
+
# attach under ai_results
|
| 514 |
+
screenings_db[screening_id].setdefault("ai_results", {})
|
| 515 |
+
screenings_db[screening_id]["ai_results"].update({
|
| 516 |
+
"vlm_features": vlm_features
|
| 517 |
+
})
|
| 518 |
+
except Exception as e:
|
| 519 |
+
# Don't fail the entire pipeline for VLM errors; record them
|
| 520 |
+
logging.exception("VLM feature extraction failed")
|
| 521 |
+
screenings_db[screening_id].setdefault("ai_results", {})
|
| 522 |
+
screenings_db[screening_id]["ai_results"].update({
|
| 523 |
+
"vlm_error": str(e)
|
| 524 |
+
})
|
| 525 |
+
|
| 526 |
+
# Simulate Medical model steps (kept short)
|
| 527 |
await asyncio.sleep(1)
|
| 528 |
vlm_face_desc = "Patient appears to have normal facial tone; no severe jaundice visible."
|
| 529 |
vlm_eye_desc = "Sclera shows mild yellowing."
|
|
|
|
| 552 |
"medical_insights": medical_insights,
|
| 553 |
"processing_time_ms": 1200
|
| 554 |
}
|
| 555 |
+
# Merge ai_results while preserving vlm_features if present
|
| 556 |
+
screenings_db[screening_id].setdefault("ai_results", {})
|
| 557 |
+
screenings_db[screening_id]["ai_results"].update(ai_results)
|
| 558 |
|
| 559 |
disease_predictions = [
|
| 560 |
{
|