|
|
""" |
|
|
Elderly HealthWatch AI Backend (FastAPI) - With GCS Support and Detailed Logging |
|
|
Enhanced with comprehensive logging at every step |
|
|
""" |
|
|
|
|
|
import io |
|
|
import os |
|
|
import uuid |
|
|
import json |
|
|
import asyncio |
|
|
import logging |
|
|
import traceback |
|
|
import re |
|
|
from typing import Dict, Any, Optional, Tuple |
|
|
from datetime import datetime, timedelta |
|
|
|
|
|
from fastapi import FastAPI, UploadFile, File, BackgroundTasks, HTTPException |
|
|
from fastapi.middleware.cors import CORSMiddleware |
|
|
from PIL import Image |
|
|
import numpy as np |
|
|
import cv2 |
|
|
|
|
|
|
|
|
try: |
|
|
from google.cloud import storage |
|
|
from google.oauth2 import service_account |
|
|
GCS_AVAILABLE = True |
|
|
except Exception: |
|
|
GCS_AVAILABLE = False |
|
|
logging.warning("Google Cloud Storage not available") |
|
|
|
|
|
try: |
|
|
from gradio_client import Client, handle_file |
|
|
GRADIO_AVAILABLE = True |
|
|
except Exception: |
|
|
GRADIO_AVAILABLE = False |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
logging.basicConfig( |
|
|
level=logging.INFO, |
|
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' |
|
|
) |
|
|
logger = logging.getLogger("elderly_healthwatch") |
|
|
|
|
|
GRADIO_VLM_SPACE = os.getenv("GRADIO_SPACE", "developer0hye/Qwen3-VL-8B-Instruct") |
|
|
LLM_GRADIO_SPACE = os.getenv("LLM_GRADIO_SPACE", "Tonic/med-gpt-oss-20b-demo") |
|
|
HF_TOKEN = os.getenv("HF_TOKEN", None) |
|
|
|
|
|
|
|
|
GCS_BUCKET_NAME = "elderly-healthwatch-images" |
|
|
GCS_CREDENTIALS_FILE = "gcs-credentials.json" |
|
|
|
|
|
DEFAULT_VLM_PROMPT = ( |
|
|
"From the provided face/eye images, compute the required screening features " |
|
|
"(pallor, sclera yellowness, redness, mobility metrics, quality checks) " |
|
|
"and output a clean JSON feature vector only." |
|
|
) |
|
|
|
|
|
LLM_SYSTEM_PROMPT = ( |
|
|
"System: This assistant MUST ONLY OUTPUT a single valid JSON object as its response — " |
|
|
"no prose, no explanations, no code fences, no annotations." |
|
|
) |
|
|
|
|
|
LLM_DEVELOPER_PROMPT = ( |
|
|
"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." |
|
|
) |
|
|
|
|
|
TMP_DIR = "/tmp/elderly_healthwatch" |
|
|
os.makedirs(TMP_DIR, exist_ok=True) |
|
|
|
|
|
|
|
|
screenings_db: Dict[str, Dict[str, Any]] = {} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def setup_gcs_client(): |
|
|
"""Initialize GCS client from credentials file""" |
|
|
logger.info("=" * 80) |
|
|
logger.info("INITIALIZING GOOGLE CLOUD STORAGE") |
|
|
logger.info("=" * 80) |
|
|
|
|
|
if not GCS_AVAILABLE: |
|
|
logger.warning("❌ GCS libraries not installed") |
|
|
return None, None |
|
|
|
|
|
try: |
|
|
logger.info("Looking for credentials file: %s", GCS_CREDENTIALS_FILE) |
|
|
logger.info("Current working directory: %s", os.getcwd()) |
|
|
logger.info("Credentials file exists: %s", os.path.exists(GCS_CREDENTIALS_FILE)) |
|
|
|
|
|
if os.path.exists(GCS_CREDENTIALS_FILE): |
|
|
logger.info("✅ Found GCS credentials file") |
|
|
logger.info("File size: %d bytes", os.path.getsize(GCS_CREDENTIALS_FILE)) |
|
|
|
|
|
credentials = service_account.Credentials.from_service_account_file(GCS_CREDENTIALS_FILE) |
|
|
logger.info("✅ Credentials loaded successfully") |
|
|
|
|
|
client = storage.Client(credentials=credentials) |
|
|
logger.info("✅ Storage client created") |
|
|
|
|
|
bucket = client.bucket(GCS_BUCKET_NAME) |
|
|
logger.info("✅ Bucket reference obtained: %s", GCS_BUCKET_NAME) |
|
|
|
|
|
|
|
|
try: |
|
|
bucket.exists() |
|
|
logger.info("✅ Bucket access verified") |
|
|
except Exception as e: |
|
|
logger.warning("⚠️ Could not verify bucket access: %s", str(e)) |
|
|
|
|
|
logger.info("=" * 80) |
|
|
logger.info("✅ GCS INITIALIZATION SUCCESSFUL") |
|
|
logger.info("=" * 80) |
|
|
return client, bucket |
|
|
else: |
|
|
logger.warning("⚠️ GCS credentials file not found at: %s", GCS_CREDENTIALS_FILE) |
|
|
logger.warning("VLM will use file handles instead of URLs") |
|
|
return None, None |
|
|
|
|
|
except Exception as e: |
|
|
logger.exception("❌ Failed to initialize GCS: %s", str(e)) |
|
|
return None, None |
|
|
|
|
|
gcs_client, gcs_bucket = setup_gcs_client() |
|
|
|
|
|
def upload_to_gcs(local_path: str, blob_name: str) -> Optional[str]: |
|
|
"""Upload file to GCS and return public URL""" |
|
|
logger.info("-" * 80) |
|
|
logger.info("UPLOADING TO GCS") |
|
|
logger.info(" - Local path: %s", local_path) |
|
|
logger.info(" - Blob name: %s", blob_name) |
|
|
logger.info(" - File exists: %s", os.path.exists(local_path)) |
|
|
|
|
|
if os.path.exists(local_path): |
|
|
logger.info(" - File size: %d bytes", os.path.getsize(local_path)) |
|
|
|
|
|
if gcs_bucket is None: |
|
|
logger.warning(" ❌ GCS bucket not available") |
|
|
return None |
|
|
|
|
|
try: |
|
|
blob = gcs_bucket.blob(blob_name) |
|
|
logger.info(" - Blob object created") |
|
|
|
|
|
blob.upload_from_filename(local_path, content_type='image/jpeg') |
|
|
logger.info(" ✅ File uploaded to GCS") |
|
|
|
|
|
blob.make_public() |
|
|
logger.info(" ✅ Blob made public") |
|
|
|
|
|
public_url = blob.public_url |
|
|
logger.info(" ✅ Public URL: %s", public_url) |
|
|
logger.info(" - URL length: %d", len(public_url)) |
|
|
|
|
|
return public_url |
|
|
except Exception as e: |
|
|
logger.exception("❌ Failed to upload to GCS: %s", str(e)) |
|
|
return None |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def setup_face_detector(): |
|
|
"""Initialize face detector (MTCNN or OpenCV fallback)""" |
|
|
|
|
|
try: |
|
|
from facenet_pytorch import MTCNN |
|
|
return MTCNN(keep_all=False, device="cpu"), "facenet_pytorch" |
|
|
except Exception: |
|
|
pass |
|
|
|
|
|
|
|
|
try: |
|
|
from mtcnn import MTCNN |
|
|
return MTCNN(), "mtcnn" |
|
|
except Exception: |
|
|
pass |
|
|
|
|
|
|
|
|
try: |
|
|
face_path = os.path.join(cv2.data.haarcascades, "haarcascade_frontalface_default.xml") |
|
|
eye_path = os.path.join(cv2.data.haarcascades, "haarcascade_eye.xml") |
|
|
if os.path.exists(face_path) and os.path.exists(eye_path): |
|
|
return { |
|
|
"impl": "opencv", |
|
|
"face_cascade": cv2.CascadeClassifier(face_path), |
|
|
"eye_cascade": cv2.CascadeClassifier(eye_path) |
|
|
}, "opencv" |
|
|
except Exception: |
|
|
pass |
|
|
|
|
|
return None, None |
|
|
|
|
|
face_detector, detector_type = setup_face_detector() |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def load_image_from_bytes(bytes_data: bytes) -> Image.Image: |
|
|
logger.info("Loading image from bytes (size: %d)", len(bytes_data)) |
|
|
img = Image.open(io.BytesIO(bytes_data)).convert("RGB") |
|
|
logger.info(" - Image loaded: %s, size: %s", img.mode, img.size) |
|
|
return img |
|
|
|
|
|
def normalize_probability(val: Optional[float]) -> float: |
|
|
"""Normalize probability to 0-1 range""" |
|
|
if val is None: |
|
|
return 0.0 |
|
|
if val > 1.0 and val <= 100.0: |
|
|
return max(0.0, min(1.0, val / 100.0)) |
|
|
if val > 100.0: |
|
|
return 1.0 |
|
|
return max(0.0, min(1.0, val)) |
|
|
|
|
|
def normalize_risk_score(val: Optional[float]) -> float: |
|
|
"""Normalize risk score to 0-100 range""" |
|
|
if val is None: |
|
|
return 0.0 |
|
|
if val <= 1.0: |
|
|
return round(max(0.0, min(100.0, val * 100.0)), 2) |
|
|
return round(max(0.0, min(100.0, val)), 2) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def detect_face_and_eyes(pil_img: Image.Image) -> Dict[str, Any]: |
|
|
"""Detect face and eyes, return quality metrics""" |
|
|
if face_detector is None: |
|
|
return { |
|
|
"face_detected": False, |
|
|
"face_confidence": 0.0, |
|
|
"eye_openness_score": 0.0, |
|
|
"left_eye": None, |
|
|
"right_eye": None |
|
|
} |
|
|
|
|
|
img_arr = np.asarray(pil_img) |
|
|
|
|
|
|
|
|
if detector_type == "facenet_pytorch": |
|
|
try: |
|
|
boxes, probs, landmarks = face_detector.detect(pil_img, landmarks=True) |
|
|
if boxes is None or len(boxes) == 0: |
|
|
return {"face_detected": False, "face_confidence": 0.0, "eye_openness_score": 0.0, |
|
|
"left_eye": None, "right_eye": None} |
|
|
|
|
|
confidence = float(probs[0]) if probs is not None else 0.0 |
|
|
lm = landmarks[0] if landmarks is not None else None |
|
|
left_eye = right_eye = None |
|
|
|
|
|
if lm is not None and len(lm) >= 2: |
|
|
left_eye = {"x": float(lm[0][0]), "y": float(lm[0][1])} |
|
|
right_eye = {"x": float(lm[1][0]), "y": float(lm[1][1])} |
|
|
|
|
|
return { |
|
|
"face_detected": True, |
|
|
"face_confidence": confidence, |
|
|
"eye_openness_score": min(max(confidence * 1.15, 0.0), 1.0), |
|
|
"left_eye": left_eye, |
|
|
"right_eye": right_eye |
|
|
} |
|
|
except Exception as e: |
|
|
logger.exception("Facenet MTCNN detection failed") |
|
|
return {"face_detected": False, "face_confidence": 0.0, "eye_openness_score": 0.0, |
|
|
"left_eye": None, "right_eye": None} |
|
|
|
|
|
|
|
|
elif detector_type == "mtcnn": |
|
|
try: |
|
|
detections = face_detector.detect_faces(img_arr) |
|
|
if not detections: |
|
|
return {"face_detected": False, "face_confidence": 0.0, "eye_openness_score": 0.0, |
|
|
"left_eye": None, "right_eye": None} |
|
|
|
|
|
face = detections[0] |
|
|
keypoints = face.get("keypoints", {}) |
|
|
confidence = float(face.get("confidence", 0.0)) |
|
|
|
|
|
return { |
|
|
"face_detected": True, |
|
|
"face_confidence": confidence, |
|
|
"eye_openness_score": min(max(confidence * 1.15, 0.0), 1.0), |
|
|
"left_eye": keypoints.get("left_eye"), |
|
|
"right_eye": keypoints.get("right_eye") |
|
|
} |
|
|
except Exception as e: |
|
|
logger.exception("Classic MTCNN detection failed") |
|
|
return {"face_detected": False, "face_confidence": 0.0, "eye_openness_score": 0.0, |
|
|
"left_eye": None, "right_eye": None} |
|
|
|
|
|
|
|
|
elif detector_type == "opencv": |
|
|
try: |
|
|
gray = cv2.cvtColor(img_arr, cv2.COLOR_RGB2GRAY) |
|
|
faces = face_detector["face_cascade"].detectMultiScale( |
|
|
gray, scaleFactor=1.1, minNeighbors=4, minSize=(60, 60) |
|
|
) |
|
|
|
|
|
if len(faces) == 0: |
|
|
return {"face_detected": False, "face_confidence": 0.0, "eye_openness_score": 0.0, |
|
|
"left_eye": None, "right_eye": None} |
|
|
|
|
|
(x, y, w, h) = faces[0] |
|
|
roi_gray = gray[y:y+h, x:x+w] |
|
|
eyes = face_detector["eye_cascade"].detectMultiScale( |
|
|
roi_gray, scaleFactor=1.1, minNeighbors=5, minSize=(20, 10) |
|
|
) |
|
|
|
|
|
eye_openness = 1.0 if len(eyes) >= 1 else 0.0 |
|
|
left_eye = None |
|
|
|
|
|
if len(eyes) >= 1: |
|
|
ex, ey, ew, eh = eyes[0] |
|
|
left_eye = {"x": float(x + ex + ew/2), "y": float(y + ey + eh/2)} |
|
|
|
|
|
confidence = min(1.0, (w*h) / (img_arr.shape[0]*img_arr.shape[1]) * 4.0) |
|
|
|
|
|
return { |
|
|
"face_detected": True, |
|
|
"face_confidence": confidence, |
|
|
"eye_openness_score": eye_openness, |
|
|
"left_eye": left_eye, |
|
|
"right_eye": None |
|
|
} |
|
|
except Exception as e: |
|
|
logger.exception("OpenCV detection failed") |
|
|
return {"face_detected": False, "face_confidence": 0.0, "eye_openness_score": 0.0, |
|
|
"left_eye": None, "right_eye": None} |
|
|
|
|
|
return {"face_detected": False, "face_confidence": 0.0, "eye_openness_score": 0.0, |
|
|
"left_eye": None, "right_eye": None} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def extract_json_from_llm_output(raw_text: str) -> Dict[str, Any]: |
|
|
"""Extract and normalize JSON from LLM output using regex""" |
|
|
match = re.search(r"\{[\s\S]*\}", raw_text) |
|
|
if not match: |
|
|
raise ValueError("No JSON-like block found in LLM output") |
|
|
|
|
|
block = match.group(0) |
|
|
|
|
|
def find_number(key: str) -> Optional[float]: |
|
|
patterns = [ |
|
|
rf'"{key}"\s*:\s*["\']?\s*([-+]?\d+(\.\d+)?)\s*%?\s*["\']?', |
|
|
rf"'{key}'\s*:\s*['\"]?\s*([-+]?\d+(\.\d+)?)\s*%?\s*['\"]?", |
|
|
rf'\b{key}\b\s*:\s*["\']?\s*([-+]?\d+(\.\d+)?)\s*%?\s*["\']?', |
|
|
] |
|
|
for pat in patterns: |
|
|
m = re.search(pat, block, flags=re.IGNORECASE) |
|
|
if m and m.group(1): |
|
|
try: |
|
|
return float(m.group(1).replace("%", "").strip()) |
|
|
except Exception: |
|
|
pass |
|
|
return None |
|
|
|
|
|
def find_text(key: str) -> str: |
|
|
m = re.search(rf'"{key}"\s*:\s*"([^"]*)"', block, flags=re.IGNORECASE) |
|
|
if m: |
|
|
return m.group(1).strip() |
|
|
m = re.search(rf"'{key}'\s*:\s*'([^']*)'", block, flags=re.IGNORECASE) |
|
|
if m: |
|
|
return m.group(1).strip() |
|
|
m = re.search(rf'\b{key}\b\s*:\s*([^\n,}}]+)', block, flags=re.IGNORECASE) |
|
|
if m: |
|
|
return m.group(1).strip().strip('",') |
|
|
return "" |
|
|
|
|
|
return { |
|
|
"risk_score": normalize_risk_score(find_number("risk_score")), |
|
|
"jaundice_probability": round(normalize_probability(find_number("jaundice_probability")), 4), |
|
|
"anemia_probability": round(normalize_probability(find_number("anemia_probability")), 4), |
|
|
"hydration_issue_probability": round(normalize_probability(find_number("hydration_issue_probability")), 4), |
|
|
"neurological_issue_probability": round(normalize_probability(find_number("neurological_issue_probability")), 4), |
|
|
"confidence": round(normalize_probability(find_number("confidence")), 4), |
|
|
"summary": find_text("summary"), |
|
|
"recommendation": find_text("recommendation") |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_gradio_client(space: str) -> Client: |
|
|
"""Get Gradio client with optional auth""" |
|
|
if not GRADIO_AVAILABLE: |
|
|
raise RuntimeError("gradio_client not installed") |
|
|
return Client(space, hf_token=HF_TOKEN) if HF_TOKEN else Client(space) |
|
|
|
|
|
def call_vlm_with_urls(face_url: str, eye_url: str, prompt: Optional[str] = None) -> Tuple[Optional[Dict], str]: |
|
|
"""Call VLM using image URLs instead of file handles""" |
|
|
logger.info("=" * 80) |
|
|
logger.info("STARTING VLM CALL WITH URLS") |
|
|
logger.info("=" * 80) |
|
|
|
|
|
|
|
|
logger.info("STEP 1: Validating inputs") |
|
|
logger.info(" - Face URL provided: %s", bool(face_url)) |
|
|
logger.info(" - Face URL length: %d", len(face_url) if face_url else 0) |
|
|
logger.info(" - Face URL (full): %s", face_url) |
|
|
logger.info(" - Eye URL provided: %s", bool(eye_url)) |
|
|
logger.info(" - Eye URL length: %d", len(eye_url) if eye_url else 0) |
|
|
logger.info(" - Eye URL (full): %s", eye_url) |
|
|
|
|
|
prompt = prompt or DEFAULT_VLM_PROMPT |
|
|
logger.info(" - Prompt provided: %s", bool(prompt)) |
|
|
logger.info(" - Prompt length: %d chars", len(prompt)) |
|
|
logger.info(" - Prompt (first 200 chars): %s", prompt[:200]) |
|
|
|
|
|
|
|
|
logger.info("STEP 2: Initializing Gradio client") |
|
|
logger.info(" - VLM Space: %s", GRADIO_VLM_SPACE) |
|
|
logger.info(" - HF Token provided: %s", bool(HF_TOKEN)) |
|
|
|
|
|
try: |
|
|
client = get_gradio_client(GRADIO_VLM_SPACE) |
|
|
logger.info(" ✅ Gradio client initialized successfully") |
|
|
except Exception as e: |
|
|
logger.error(" ❌ Failed to initialize Gradio client: %s", str(e)) |
|
|
logger.exception("Client initialization error:") |
|
|
raise |
|
|
|
|
|
|
|
|
logger.info("STEP 3: Preparing message formats") |
|
|
message_formats = [ |
|
|
|
|
|
{"text": prompt, "files": [face_url, eye_url]}, |
|
|
|
|
|
{"prompt": prompt, "image1": face_url, "image2": eye_url}, |
|
|
|
|
|
{"message": f"{prompt}\n\nFace image: {face_url}\nEye image: {eye_url}"}, |
|
|
|
|
|
{"text": prompt, "images": [face_url, eye_url]}, |
|
|
] |
|
|
logger.info(" - Prepared %d message format variations", len(message_formats)) |
|
|
|
|
|
last_error = None |
|
|
|
|
|
|
|
|
for idx, message in enumerate(message_formats, 1): |
|
|
logger.info("-" * 80) |
|
|
logger.info("STEP 4.%d: Trying message format %d/%d", idx, idx, len(message_formats)) |
|
|
logger.info(" - Format keys: %s", list(message.keys())) |
|
|
logger.info(" - Format structure (first 300 chars): %s", str(message)[:300]) |
|
|
|
|
|
try: |
|
|
logger.info(" - Calling VLM API with /chat_fn endpoint...") |
|
|
result = client.predict(message=message, history=[], api_name="/chat_fn") |
|
|
logger.info(" ✅ API call succeeded!") |
|
|
|
|
|
|
|
|
logger.info("STEP 5: Processing VLM result") |
|
|
logger.info(" - Result type: %s", type(result)) |
|
|
logger.info(" - Result (first 500 chars): %s", str(result)[:500]) |
|
|
|
|
|
|
|
|
if isinstance(result, (list, tuple)): |
|
|
logger.info(" - Result is list/tuple with %d elements", len(result)) |
|
|
out = result[0] if len(result) > 0 else {} |
|
|
logger.info(" - Extracted first element, type: %s", type(out)) |
|
|
elif isinstance(result, dict): |
|
|
logger.info(" - Result is dict with keys: %s", list(result.keys())) |
|
|
out = result |
|
|
else: |
|
|
logger.info(" - Result is %s, converting to dict", type(result)) |
|
|
out = {"text": str(result)} |
|
|
|
|
|
|
|
|
logger.info("STEP 6: Extracting text from result") |
|
|
text_out = None |
|
|
|
|
|
if isinstance(out, dict): |
|
|
logger.info(" - Checking dict keys for text content...") |
|
|
text_out = out.get("text") or out.get("output") or out.get("content") or out.get("response") |
|
|
logger.info(" - Found text in key: %s", |
|
|
"text" if out.get("text") else |
|
|
"output" if out.get("output") else |
|
|
"content" if out.get("content") else |
|
|
"response" if out.get("response") else "none") |
|
|
|
|
|
if not text_out: |
|
|
logger.info(" - No text in dict, trying alternative methods") |
|
|
if isinstance(result, str): |
|
|
text_out = result |
|
|
logger.info(" - Using result as string directly") |
|
|
else: |
|
|
text_out = json.dumps(out) |
|
|
logger.info(" - Converting to JSON string") |
|
|
|
|
|
logger.info(" - Extracted text length: %d chars", len(text_out) if text_out else 0) |
|
|
logger.info(" - Extracted text (first 500 chars): %s", text_out[:500] if text_out else "EMPTY") |
|
|
logger.info(" - Extracted text (last 200 chars): %s", text_out[-200:] if text_out and len(text_out) > 200 else "") |
|
|
|
|
|
|
|
|
if not text_out or len(text_out.strip()) == 0: |
|
|
logger.warning(" ⚠️ VLM returned empty text, trying next format...") |
|
|
last_error = "Empty response" |
|
|
continue |
|
|
|
|
|
|
|
|
logger.info("STEP 7: Attempting to parse JSON from text") |
|
|
parsed = None |
|
|
|
|
|
try: |
|
|
parsed = json.loads(text_out) |
|
|
if not isinstance(parsed, dict): |
|
|
logger.warning(" ⚠️ JSON parsed but result is not a dict, type: %s", type(parsed)) |
|
|
parsed = None |
|
|
else: |
|
|
logger.info(" ✅ Successfully parsed JSON directly") |
|
|
logger.info(" - JSON keys: %s", list(parsed.keys())) |
|
|
logger.info(" - JSON (formatted): %s", json.dumps(parsed, indent=2)[:500]) |
|
|
except Exception as parse_err: |
|
|
logger.info(" - Direct JSON parsing failed: %s", str(parse_err)) |
|
|
logger.info(" - Attempting to extract JSON from surrounding text...") |
|
|
|
|
|
|
|
|
try: |
|
|
first = text_out.find("{") |
|
|
last = text_out.rfind("}") |
|
|
logger.info(" - First '{' at position: %d", first) |
|
|
logger.info(" - Last '}' at position: %d", last) |
|
|
|
|
|
if first != -1 and last != -1 and last > first: |
|
|
json_str = text_out[first:last+1] |
|
|
logger.info(" - Extracted JSON string length: %d", len(json_str)) |
|
|
logger.info(" - Extracted JSON string: %s", json_str[:300]) |
|
|
|
|
|
parsed = json.loads(json_str) |
|
|
if isinstance(parsed, dict): |
|
|
logger.info(" ✅ Successfully extracted and parsed JSON from text") |
|
|
logger.info(" - JSON keys: %s", list(parsed.keys())) |
|
|
else: |
|
|
logger.warning(" ⚠️ Extracted JSON is not a dict, type: %s", type(parsed)) |
|
|
parsed = None |
|
|
else: |
|
|
logger.warning(" ⚠️ Could not find valid JSON delimiters") |
|
|
except Exception as extract_err: |
|
|
logger.warning(" ❌ JSON extraction failed: %s", str(extract_err)) |
|
|
parsed = None |
|
|
|
|
|
|
|
|
logger.info("=" * 80) |
|
|
logger.info("✅ VLM CALL COMPLETED SUCCESSFULLY") |
|
|
logger.info(" - Using message format: %d", idx) |
|
|
logger.info(" - Parsed JSON: %s", "Yes" if parsed else "No (raw text only)") |
|
|
logger.info(" - Response length: %d chars", len(text_out)) |
|
|
logger.info("=" * 80) |
|
|
|
|
|
return parsed, text_out |
|
|
|
|
|
except Exception as e: |
|
|
logger.warning(" ❌ VLM format %d failed: %s", idx, str(e)) |
|
|
logger.exception("Detailed error for format %d:", idx) |
|
|
last_error = str(e) |
|
|
continue |
|
|
|
|
|
|
|
|
logger.error("=" * 80) |
|
|
logger.error("❌ ALL VLM MESSAGE FORMATS FAILED") |
|
|
logger.error(" - Tried %d different formats", len(message_formats)) |
|
|
logger.error(" - Last error: %s", last_error) |
|
|
logger.error("=" * 80) |
|
|
raise RuntimeError(f"All VLM message formats failed. Last error: {last_error}") |
|
|
|
|
|
def call_vlm(face_path: str, eye_path: str, prompt: Optional[str] = None) -> Tuple[Optional[Dict], str]: |
|
|
""" |
|
|
Call VLM - wrapper that handles both local files and GCS URLs |
|
|
Strategy: Try GCS first (if available), fallback to file handles |
|
|
""" |
|
|
logger.info("=" * 80) |
|
|
logger.info("VLM CALL ORCHESTRATOR") |
|
|
logger.info("=" * 80) |
|
|
logger.info("Input files:") |
|
|
logger.info(" - Face image: %s", face_path) |
|
|
logger.info(" - Eye image: %s", eye_path) |
|
|
logger.info(" - Face exists: %s", os.path.exists(face_path)) |
|
|
logger.info(" - Eye exists: %s", os.path.exists(eye_path)) |
|
|
|
|
|
if os.path.exists(face_path): |
|
|
logger.info(" - Face size: %d bytes", os.path.getsize(face_path)) |
|
|
if os.path.exists(eye_path): |
|
|
logger.info(" - Eye size: %d bytes", os.path.getsize(eye_path)) |
|
|
|
|
|
|
|
|
if gcs_bucket is not None: |
|
|
logger.info("🌐 GCS is available, attempting URL-based VLM call") |
|
|
|
|
|
try: |
|
|
|
|
|
unique_id = str(uuid.uuid4()) |
|
|
face_blob_name = f"vlm_temp/{unique_id}_face.jpg" |
|
|
eye_blob_name = f"vlm_temp/{unique_id}_eye.jpg" |
|
|
|
|
|
logger.info(" - Generated unique ID: %s", unique_id) |
|
|
logger.info(" - Face blob name: %s", face_blob_name) |
|
|
logger.info(" - Eye blob name: %s", eye_blob_name) |
|
|
|
|
|
|
|
|
logger.info("Uploading images to GCS...") |
|
|
face_url = upload_to_gcs(face_path, face_blob_name) |
|
|
eye_url = upload_to_gcs(eye_path, eye_blob_name) |
|
|
|
|
|
logger.info("Upload results:") |
|
|
logger.info(" - Face URL: %s", face_url if face_url else "FAILED") |
|
|
logger.info(" - Eye URL: %s", eye_url if eye_url else "FAILED") |
|
|
|
|
|
if face_url and eye_url: |
|
|
logger.info("✅ Successfully uploaded to GCS, calling VLM with URLs") |
|
|
return call_vlm_with_urls(face_url, eye_url, prompt) |
|
|
else: |
|
|
logger.warning("⚠️ GCS upload failed, falling back to file handles") |
|
|
except Exception as e: |
|
|
logger.warning("⚠️ GCS error, falling back to file handles: %s", str(e)) |
|
|
logger.exception("GCS error details:") |
|
|
else: |
|
|
logger.info("ℹ️ GCS not available, using file handles for VLM") |
|
|
|
|
|
|
|
|
logger.info("-" * 80) |
|
|
logger.info("USING FILE HANDLE STRATEGY") |
|
|
logger.info("-" * 80) |
|
|
|
|
|
if not os.path.exists(face_path) or not os.path.exists(eye_path): |
|
|
logger.error("❌ File paths missing!") |
|
|
logger.error(" - Face exists: %s", os.path.exists(face_path)) |
|
|
logger.error(" - Eye exists: %s", os.path.exists(eye_path)) |
|
|
raise FileNotFoundError("Face or eye image path missing") |
|
|
|
|
|
prompt = prompt or DEFAULT_VLM_PROMPT |
|
|
logger.info(" - Using prompt: %s", prompt[:100]) |
|
|
|
|
|
logger.info(" - Creating Gradio client...") |
|
|
client = get_gradio_client(GRADIO_VLM_SPACE) |
|
|
logger.info(" ✅ Client created") |
|
|
|
|
|
logger.info(" - Creating file handles...") |
|
|
face_handle = handle_file(face_path) |
|
|
eye_handle = handle_file(eye_path) |
|
|
logger.info(" ✅ File handles created") |
|
|
logger.info(" - Face handle type: %s", type(face_handle)) |
|
|
logger.info(" - Eye handle type: %s", type(eye_handle)) |
|
|
|
|
|
message = {"text": prompt, "files": [face_handle, eye_handle]} |
|
|
logger.info(" - Message structure: %s", list(message.keys())) |
|
|
|
|
|
try: |
|
|
logger.info(" - Calling VLM API...") |
|
|
result = client.predict(message=message, history=[], api_name="/chat_fn") |
|
|
logger.info(" ✅ VLM API call succeeded") |
|
|
logger.info(" - Raw result type: %s", type(result)) |
|
|
logger.info(" - Raw result (first 500 chars): %s", str(result)[:500]) |
|
|
except Exception as e: |
|
|
logger.exception("❌ VLM call with file handles failed") |
|
|
raise RuntimeError(f"VLM call failed: {e}") |
|
|
|
|
|
|
|
|
logger.info("Processing result...") |
|
|
if isinstance(result, (list, tuple)): |
|
|
out = result[0] if len(result) > 0 else {} |
|
|
logger.info(" - Extracted from list, type: %s", type(out)) |
|
|
elif isinstance(result, dict): |
|
|
out = result |
|
|
logger.info(" - Using dict directly") |
|
|
else: |
|
|
out = {"text": str(result)} |
|
|
logger.info(" - Wrapped in dict") |
|
|
|
|
|
text_out = None |
|
|
if isinstance(out, dict): |
|
|
text_out = out.get("text") or out.get("output") or out.get("content") |
|
|
logger.info(" - Extracted text from dict key") |
|
|
|
|
|
if not text_out: |
|
|
if isinstance(result, str): |
|
|
text_out = result |
|
|
logger.info(" - Using result string directly") |
|
|
else: |
|
|
text_out = json.dumps(out) |
|
|
logger.info(" - Converted to JSON string") |
|
|
|
|
|
logger.info(" - Final text length: %d", len(text_out) if text_out else 0) |
|
|
logger.info(" - Final text (first 500 chars): %s", text_out[:500] if text_out else "EMPTY") |
|
|
|
|
|
if not text_out or len(text_out.strip()) == 0: |
|
|
logger.warning(" ⚠️ VLM returned empty text") |
|
|
text_out = "{}" |
|
|
|
|
|
parsed = None |
|
|
try: |
|
|
parsed = json.loads(text_out) |
|
|
if not isinstance(parsed, dict): |
|
|
parsed = None |
|
|
else: |
|
|
logger.info(" ✅ Parsed JSON successfully") |
|
|
except Exception: |
|
|
logger.info(" - Direct JSON parse failed, trying extraction...") |
|
|
try: |
|
|
first = text_out.find("{") |
|
|
last = text_out.rfind("}") |
|
|
if first != -1 and last != -1: |
|
|
parsed = json.loads(text_out[first:last+1]) |
|
|
if not isinstance(parsed, dict): |
|
|
parsed = None |
|
|
else: |
|
|
logger.info(" ✅ Extracted and parsed JSON") |
|
|
except Exception: |
|
|
logger.warning(" ⚠️ JSON extraction failed") |
|
|
pass |
|
|
|
|
|
logger.info("=" * 80) |
|
|
logger.info("VLM CALL COMPLETED") |
|
|
logger.info(" - Parsed JSON: %s", "Yes" if parsed else "No") |
|
|
logger.info("=" * 80) |
|
|
|
|
|
return parsed, text_out |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_fallback_risk_assessment(vlm_output: Any, reason: str = "LLM unavailable") -> Dict[str, Any]: |
|
|
"""Generate basic risk assessment from VLM output when LLM is unavailable""" |
|
|
logger.warning("Using fallback risk assessment: %s", reason) |
|
|
|
|
|
vlm_dict = {} |
|
|
if isinstance(vlm_output, dict): |
|
|
vlm_dict = vlm_output |
|
|
elif isinstance(vlm_output, str): |
|
|
try: |
|
|
vlm_dict = json.loads(vlm_output) |
|
|
except Exception: |
|
|
pass |
|
|
|
|
|
has_data = bool(vlm_dict and any(vlm_dict.values())) |
|
|
|
|
|
if not has_data: |
|
|
logger.warning("VLM output is empty or invalid, returning minimal assessment") |
|
|
return { |
|
|
"risk_score": 0.0, |
|
|
"jaundice_probability": 0.0, |
|
|
"anemia_probability": 0.0, |
|
|
"hydration_issue_probability": 0.0, |
|
|
"neurological_issue_probability": 0.0, |
|
|
"confidence": 0.1, |
|
|
"summary": "Unable to analyze images. Please ensure photos are clear and well-lit.", |
|
|
"recommendation": "Retake photos with better lighting and clearer view of face and eyes.", |
|
|
"fallback_mode": True, |
|
|
"fallback_reason": "no_vlm_data" |
|
|
} |
|
|
|
|
|
|
|
|
risk_score = 20.0 |
|
|
jaundice_prob = 0.0 |
|
|
anemia_prob = 0.0 |
|
|
hydration_prob = 0.0 |
|
|
neuro_prob = 0.0 |
|
|
|
|
|
sclera_yellow = vlm_dict.get("sclera_yellowness", 0) |
|
|
pallor = vlm_dict.get("pallor_score", 0) |
|
|
redness = vlm_dict.get("redness", 0) |
|
|
|
|
|
if isinstance(sclera_yellow, (int, float)) and sclera_yellow > 0.3: |
|
|
jaundice_prob = min(0.6, sclera_yellow) |
|
|
risk_score += 15 |
|
|
|
|
|
if isinstance(pallor, (int, float)) and pallor > 0.4: |
|
|
anemia_prob = min(0.7, pallor) |
|
|
risk_score += 20 |
|
|
|
|
|
if isinstance(redness, (int, float)) and redness > 0.5: |
|
|
hydration_prob = min(0.5, redness * 0.8) |
|
|
risk_score += 10 |
|
|
|
|
|
return { |
|
|
"risk_score": round(min(100.0, risk_score), 2), |
|
|
"jaundice_probability": round(jaundice_prob, 4), |
|
|
"anemia_probability": round(anemia_prob, 4), |
|
|
"hydration_issue_probability": round(hydration_prob, 4), |
|
|
"neurological_issue_probability": round(neuro_prob, 4), |
|
|
"confidence": 0.4, |
|
|
"summary": "Basic screening completed. Advanced AI analysis temporarily unavailable.", |
|
|
"recommendation": "Consider consulting a healthcare professional for a comprehensive assessment.", |
|
|
"fallback_mode": True, |
|
|
"fallback_reason": reason |
|
|
} |
|
|
|
|
|
def call_llm(vlm_output: Any, use_fallback_on_error: bool = True) -> Dict[str, Any]: |
|
|
"""Call LLM with VLM output and return structured risk assessment""" |
|
|
logger.info("=" * 80) |
|
|
logger.info("STARTING LLM CALL") |
|
|
logger.info("=" * 80) |
|
|
|
|
|
if not GRADIO_AVAILABLE: |
|
|
logger.error("❌ Gradio not available") |
|
|
if use_fallback_on_error: |
|
|
return get_fallback_risk_assessment(vlm_output, reason="gradio_not_available") |
|
|
raise RuntimeError("gradio_client not installed") |
|
|
|
|
|
vlm_text = vlm_output if isinstance(vlm_output, str) else json.dumps(vlm_output, default=str) |
|
|
logger.info("VLM input to LLM:") |
|
|
logger.info(" - Type: %s", type(vlm_output)) |
|
|
logger.info(" - Length: %d chars", len(vlm_text)) |
|
|
logger.info(" - Content (first 500 chars): %s", vlm_text[:500]) |
|
|
|
|
|
if not vlm_text or vlm_text.strip() in ["{}", "[]", ""]: |
|
|
logger.warning("VLM output is empty, using fallback assessment") |
|
|
if use_fallback_on_error: |
|
|
return get_fallback_risk_assessment(vlm_output, reason="empty_vlm_output") |
|
|
raise RuntimeError("VLM output is empty") |
|
|
|
|
|
instruction = ( |
|
|
"\n\nSTRICT INSTRUCTIONS:\n" |
|
|
"1) OUTPUT ONLY a single valid JSON object — no prose, no code fences.\n" |
|
|
"2) Include keys: risk_score, jaundice_probability, anemia_probability, " |
|
|
"hydration_issue_probability, neurological_issue_probability, summary, recommendation, confidence.\n" |
|
|
"3) Use numeric values for probabilities (0-1) and risk_score (0-100).\n" |
|
|
"4) Use neutral wording in summary/recommendation.\n" |
|
|
"5) If VLM data is minimal or unclear, set low probabilities and low confidence.\n\n" |
|
|
"VLM Output:\n" + vlm_text + "\n" |
|
|
) |
|
|
|
|
|
logger.info("LLM instruction length: %d chars", len(instruction)) |
|
|
|
|
|
try: |
|
|
logger.info("Creating LLM client for space: %s", LLM_GRADIO_SPACE) |
|
|
client = get_gradio_client(LLM_GRADIO_SPACE) |
|
|
logger.info("✅ LLM client created") |
|
|
|
|
|
logger.info("Calling LLM with parameters:") |
|
|
logger.info(" - max_new_tokens: 1024") |
|
|
logger.info(" - temperature: 0.2") |
|
|
logger.info(" - top_p: 0.9") |
|
|
|
|
|
result = client.predict( |
|
|
input_data=instruction, |
|
|
max_new_tokens=1024.0, |
|
|
model_identity=os.getenv("LLM_MODEL_IDENTITY", "GPT-Tonic"), |
|
|
system_prompt=LLM_SYSTEM_PROMPT, |
|
|
developer_prompt=LLM_DEVELOPER_PROMPT, |
|
|
reasoning_effort="medium", |
|
|
temperature=0.2, |
|
|
top_p=0.9, |
|
|
top_k=50, |
|
|
repetition_penalty=1.0, |
|
|
api_name="/chat" |
|
|
) |
|
|
|
|
|
logger.info("✅ LLM API call succeeded") |
|
|
logger.info("LLM result type: %s", type(result)) |
|
|
|
|
|
text_out = json.dumps(result) if isinstance(result, (dict, list)) else str(result) |
|
|
logger.info("LLM raw output length: %d chars", len(text_out)) |
|
|
logger.info("LLM raw output (first 1000 chars):\n%s", text_out[:1000]) |
|
|
|
|
|
logger.info("Attempting to extract JSON from LLM output...") |
|
|
parsed = extract_json_from_llm_output(text_out) |
|
|
logger.info("✅ JSON extraction successful") |
|
|
logger.info("Parsed LLM JSON:\n%s", json.dumps(parsed, indent=2)) |
|
|
|
|
|
|
|
|
all_zero = all( |
|
|
parsed.get(k, 0) == 0 |
|
|
for k in ["jaundice_probability", "anemia_probability", |
|
|
"hydration_issue_probability", "neurological_issue_probability"] |
|
|
) |
|
|
|
|
|
if all_zero and parsed.get("risk_score", 0) == 0: |
|
|
logger.warning("⚠️ LLM returned all-zero assessment") |
|
|
parsed["summary"] = "Image analysis incomplete. Please ensure photos are clear and well-lit." |
|
|
parsed["recommendation"] = "Retake photos with face clearly visible and eyes open." |
|
|
parsed["confidence"] = 0.1 |
|
|
|
|
|
logger.info("=" * 80) |
|
|
logger.info("✅ LLM CALL COMPLETED SUCCESSFULLY") |
|
|
logger.info("=" * 80) |
|
|
return parsed |
|
|
|
|
|
except Exception as e: |
|
|
logger.exception("❌ LLM call failed: %s", str(e)) |
|
|
|
|
|
error_msg = str(e).lower() |
|
|
if "quota" in error_msg or "gpu" in error_msg: |
|
|
logger.warning("GPU quota exceeded, using fallback") |
|
|
if use_fallback_on_error: |
|
|
return get_fallback_risk_assessment(vlm_output, reason="gpu_quota_exceeded") |
|
|
|
|
|
if use_fallback_on_error: |
|
|
return get_fallback_risk_assessment(vlm_output, reason=f"llm_error: {str(e)[:100]}") |
|
|
|
|
|
raise RuntimeError(f"LLM call failed: {e}") |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def process_screening(screening_id: str): |
|
|
"""Main processing pipeline""" |
|
|
logger.info("=" * 80) |
|
|
logger.info("BACKGROUND PROCESSING STARTED FOR: %s", screening_id) |
|
|
logger.info("=" * 80) |
|
|
|
|
|
try: |
|
|
if screening_id not in screenings_db: |
|
|
logger.error("Screening %s not found in database", screening_id) |
|
|
return |
|
|
|
|
|
screenings_db[screening_id]["status"] = "processing" |
|
|
logger.info("Status updated to: processing") |
|
|
|
|
|
entry = screenings_db[screening_id] |
|
|
face_path = entry["face_image_path"] |
|
|
eye_path = entry["eye_image_path"] |
|
|
|
|
|
logger.info("Processing images:") |
|
|
logger.info(" - Face: %s", face_path) |
|
|
logger.info(" - Eye: %s", eye_path) |
|
|
|
|
|
|
|
|
logger.info("Loading face image for quality check...") |
|
|
face_img = Image.open(face_path).convert("RGB") |
|
|
logger.info(" ✅ Face image loaded: %s", face_img.size) |
|
|
|
|
|
logger.info("Running face detection...") |
|
|
detection_result = detect_face_and_eyes(face_img) |
|
|
logger.info(" - Face detected: %s", detection_result["face_detected"]) |
|
|
logger.info(" - Face confidence: %.3f", detection_result["face_confidence"]) |
|
|
|
|
|
quality_metrics = { |
|
|
"face_detected": detection_result["face_detected"], |
|
|
"face_confidence": round(detection_result["face_confidence"], 3), |
|
|
"face_quality_score": 0.85 if detection_result["face_detected"] else 0.45, |
|
|
"eye_coords": { |
|
|
"left_eye": detection_result["left_eye"], |
|
|
"right_eye": detection_result["right_eye"] |
|
|
}, |
|
|
"face_brightness": int(np.mean(np.asarray(face_img.convert("L")))), |
|
|
"face_blur_estimate": int(np.var(np.asarray(face_img.convert("L")))) |
|
|
} |
|
|
screenings_db[screening_id]["quality_metrics"] = quality_metrics |
|
|
logger.info("✅ Quality metrics computed and stored") |
|
|
|
|
|
|
|
|
logger.info("Calling VLM...") |
|
|
vlm_features, vlm_raw = await asyncio.to_thread(call_vlm, face_path, eye_path) |
|
|
logger.info("✅ VLM call completed") |
|
|
logger.info(" - Features returned: %s", bool(vlm_features)) |
|
|
logger.info(" - Raw output length: %d", len(vlm_raw) if vlm_raw else 0) |
|
|
|
|
|
|
|
|
logger.info("Calling LLM...") |
|
|
llm_input = vlm_raw if vlm_raw else (vlm_features if vlm_features else "{}") |
|
|
structured_risk = await asyncio.to_thread(call_llm, llm_input, use_fallback_on_error=True) |
|
|
logger.info("✅ LLM call completed") |
|
|
logger.info(" - Risk score: %.2f", structured_risk.get("risk_score", 0)) |
|
|
logger.info(" - Using fallback: %s", structured_risk.get("fallback_mode", False)) |
|
|
|
|
|
|
|
|
screenings_db[screening_id]["ai_results"] = { |
|
|
"vlm_features": vlm_features, |
|
|
"vlm_raw": vlm_raw, |
|
|
"structured_risk": structured_risk, |
|
|
"processing_time_ms": 1200 |
|
|
} |
|
|
|
|
|
|
|
|
disease_predictions = [ |
|
|
{ |
|
|
"condition": "Anemia-like-signs", |
|
|
"risk_level": "Medium" if structured_risk["anemia_probability"] > 0.5 else "Low", |
|
|
"probability": structured_risk["anemia_probability"], |
|
|
"confidence": structured_risk["confidence"] |
|
|
}, |
|
|
{ |
|
|
"condition": "Jaundice-like-signs", |
|
|
"risk_level": "Medium" if structured_risk["jaundice_probability"] > 0.5 else "Low", |
|
|
"probability": structured_risk["jaundice_probability"], |
|
|
"confidence": structured_risk["confidence"] |
|
|
} |
|
|
] |
|
|
|
|
|
recommendations = { |
|
|
"action_needed": "consult" if structured_risk["risk_score"] > 30.0 else "monitor", |
|
|
"message_english": structured_risk["recommendation"] or "Please follow up with a health professional if concerns persist.", |
|
|
"message_hindi": "" |
|
|
} |
|
|
|
|
|
screenings_db[screening_id].update({ |
|
|
"status": "completed", |
|
|
"disease_predictions": disease_predictions, |
|
|
"recommendations": recommendations |
|
|
}) |
|
|
|
|
|
logger.info("=" * 80) |
|
|
logger.info("✅ PROCESSING COMPLETED SUCCESSFULLY FOR: %s", screening_id) |
|
|
logger.info("=" * 80) |
|
|
|
|
|
except Exception as e: |
|
|
logger.exception("❌ Processing failed for %s", screening_id) |
|
|
if screening_id in screenings_db: |
|
|
screenings_db[screening_id]["status"] = "failed" |
|
|
screenings_db[screening_id]["error"] = str(e) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app = FastAPI(title="Elderly HealthWatch AI Backend - Enhanced Logging") |
|
|
app.add_middleware( |
|
|
CORSMiddleware, |
|
|
allow_origins=["*"], |
|
|
allow_credentials=True, |
|
|
allow_methods=["*"], |
|
|
allow_headers=["*"], |
|
|
) |
|
|
|
|
|
@app.get("/") |
|
|
async def read_root(): |
|
|
return { |
|
|
"message": "Elderly HealthWatch AI Backend - Enhanced Logging Version", |
|
|
"gcs_enabled": gcs_bucket is not None, |
|
|
"version": "1.0.0-gcs-logging" |
|
|
} |
|
|
|
|
|
@app.get("/health") |
|
|
async def health_check(): |
|
|
"""Health check with GCS and LLM status""" |
|
|
llm_status = "available" |
|
|
llm_message = None |
|
|
|
|
|
if GRADIO_AVAILABLE: |
|
|
try: |
|
|
client = get_gradio_client(LLM_GRADIO_SPACE) |
|
|
llm_status = "available" |
|
|
except Exception as e: |
|
|
error_msg = str(e).lower() |
|
|
if "quota" in error_msg or "gpu" in error_msg: |
|
|
llm_status = "quota_exceeded" |
|
|
llm_message = "GPU quota exceeded. Using fallback." |
|
|
else: |
|
|
llm_status = "error" |
|
|
llm_message = "LLM temporarily unavailable" |
|
|
else: |
|
|
llm_status = "not_installed" |
|
|
llm_message = "Gradio not available" |
|
|
|
|
|
return { |
|
|
"status": "healthy", |
|
|
"detector": detector_type or "none", |
|
|
"vlm_available": GRADIO_AVAILABLE, |
|
|
"vlm_space": GRADIO_VLM_SPACE, |
|
|
"llm_space": LLM_GRADIO_SPACE, |
|
|
"llm_status": llm_status, |
|
|
"llm_message": llm_message, |
|
|
"gcs_available": gcs_bucket is not None, |
|
|
"gcs_bucket": GCS_BUCKET_NAME if gcs_bucket else None, |
|
|
"fallback_enabled": True, |
|
|
"enhanced_logging": True |
|
|
} |
|
|
|
|
|
@app.post("/api/v1/validate-eye-photo") |
|
|
async def validate_eye_photo(image: UploadFile = File(...)): |
|
|
"""Validate eye photo quality""" |
|
|
if face_detector is None: |
|
|
raise HTTPException(status_code=500, detail="No face detector available") |
|
|
|
|
|
try: |
|
|
content = await image.read() |
|
|
if not content: |
|
|
raise HTTPException(status_code=400, detail="Empty file") |
|
|
|
|
|
pil_img = load_image_from_bytes(content) |
|
|
result = detect_face_and_eyes(pil_img) |
|
|
|
|
|
if not result["face_detected"]: |
|
|
return { |
|
|
"valid": False, |
|
|
"face_detected": False, |
|
|
"eye_openness_score": 0.0, |
|
|
"message_english": "No face detected. Please ensure your face is clearly visible.", |
|
|
"message_hindi": "कोई चेहरा नहीं मिला। कृपया सुनिश्चित करें कि आपका चेहरा स्पष्ट रूप से दिखाई दे।" |
|
|
} |
|
|
|
|
|
is_valid = result["eye_openness_score"] >= 0.3 |
|
|
|
|
|
return { |
|
|
"valid": is_valid, |
|
|
"face_detected": True, |
|
|
"eye_openness_score": round(result["eye_openness_score"], 2), |
|
|
"message_english": "Photo looks good! Eyes are properly open." if is_valid |
|
|
else "Eyes appear closed. Please open your eyes wide and try again.", |
|
|
"message_hindi": "फोटो अच्छी है! आंखें ठीक से खुली हैं।" if is_valid |
|
|
else "आंखें बंद दिखाई दे रही हैं। कृपया अपनी आंखें चौड़ी खोलें।", |
|
|
"eye_landmarks": { |
|
|
"left_eye": result["left_eye"], |
|
|
"right_eye": result["right_eye"] |
|
|
} |
|
|
} |
|
|
|
|
|
except Exception as e: |
|
|
logger.exception("Validation failed") |
|
|
raise HTTPException(status_code=500, detail=str(e)) |
|
|
|
|
|
@app.post("/api/v1/upload") |
|
|
async def upload_images( |
|
|
background_tasks: BackgroundTasks, |
|
|
face_image: UploadFile = File(...), |
|
|
eye_image: UploadFile = File(...) |
|
|
): |
|
|
"""Upload images and start background processing""" |
|
|
logger.info("=" * 80) |
|
|
logger.info("NEW UPLOAD REQUEST") |
|
|
logger.info("=" * 80) |
|
|
|
|
|
try: |
|
|
screening_id = str(uuid.uuid4()) |
|
|
logger.info("Generated screening ID: %s", screening_id) |
|
|
|
|
|
face_path = os.path.join(TMP_DIR, f"{screening_id}_face.jpg") |
|
|
eye_path = os.path.join(TMP_DIR, f"{screening_id}_eye.jpg") |
|
|
|
|
|
logger.info("Reading uploaded files...") |
|
|
face_bytes = await face_image.read() |
|
|
eye_bytes = await eye_image.read() |
|
|
logger.info(" - Face image: %d bytes", len(face_bytes)) |
|
|
logger.info(" - Eye image: %d bytes", len(eye_bytes)) |
|
|
|
|
|
logger.info("Saving images to disk...") |
|
|
with open(face_path, "wb") as f: |
|
|
f.write(face_bytes) |
|
|
with open(eye_path, "wb") as f: |
|
|
f.write(eye_bytes) |
|
|
logger.info(" ✅ Images saved") |
|
|
|
|
|
screenings_db[screening_id] = { |
|
|
"id": screening_id, |
|
|
"timestamp": datetime.utcnow().isoformat() + "Z", |
|
|
"face_image_path": face_path, |
|
|
"eye_image_path": eye_path, |
|
|
"status": "queued", |
|
|
"quality_metrics": {}, |
|
|
"ai_results": {}, |
|
|
"disease_predictions": [], |
|
|
"recommendations": {} |
|
|
} |
|
|
|
|
|
logger.info("Adding background task...") |
|
|
background_tasks.add_task(process_screening, screening_id) |
|
|
logger.info("✅ Upload successful, processing queued") |
|
|
|
|
|
return {"screening_id": screening_id} |
|
|
|
|
|
except Exception as e: |
|
|
logger.exception("❌ Upload failed") |
|
|
raise HTTPException(status_code=500, detail=str(e)) |
|
|
|
|
|
@app.post("/api/v1/analyze/{screening_id}") |
|
|
async def analyze_screening(screening_id: str, background_tasks: BackgroundTasks): |
|
|
"""Re-analyze existing screening""" |
|
|
if screening_id not in screenings_db: |
|
|
raise HTTPException(status_code=404, detail="Screening not found") |
|
|
|
|
|
if screenings_db[screening_id]["status"] == "processing": |
|
|
return {"message": "Already processing"} |
|
|
|
|
|
screenings_db[screening_id]["status"] = "queued" |
|
|
background_tasks.add_task(process_screening, screening_id) |
|
|
|
|
|
return {"message": "Analysis enqueued"} |
|
|
|
|
|
@app.get("/api/v1/status/{screening_id}") |
|
|
async def get_status(screening_id: str): |
|
|
"""Get processing status""" |
|
|
if screening_id not in screenings_db: |
|
|
raise HTTPException(status_code=404, detail="Screening not found") |
|
|
|
|
|
status = screenings_db[screening_id]["status"] |
|
|
progress = 50 if status == "processing" else (100 if status == "completed" else 0) |
|
|
|
|
|
return {"screening_id": screening_id, "status": status, "progress": progress} |
|
|
|
|
|
@app.get("/api/v1/results/{screening_id}") |
|
|
async def get_results(screening_id: str): |
|
|
"""Get screening results""" |
|
|
if screening_id not in screenings_db: |
|
|
raise HTTPException(status_code=404, detail="Screening not found") |
|
|
|
|
|
return screenings_db[screening_id] |
|
|
|
|
|
@app.get("/api/v1/history/{user_id}") |
|
|
async def get_history(user_id: str): |
|
|
"""Get user screening history""" |
|
|
history = [s for s in screenings_db.values() if s.get("user_id") == user_id] |
|
|
return {"screenings": history} |
|
|
|
|
|
@app.post("/api/v1/get-vitals") |
|
|
async def get_vitals_from_upload( |
|
|
face_image: UploadFile = File(...), |
|
|
eye_image: UploadFile = File(...) |
|
|
): |
|
|
"""Synchronous VLM + LLM pipeline with GCS support""" |
|
|
logger.info("=" * 80) |
|
|
logger.info("GET VITALS REQUEST (SYNCHRONOUS)") |
|
|
logger.info("=" * 80) |
|
|
|
|
|
if not GRADIO_AVAILABLE: |
|
|
raise HTTPException( |
|
|
status_code=503, |
|
|
detail="AI services temporarily unavailable." |
|
|
) |
|
|
|
|
|
try: |
|
|
uid = str(uuid.uuid4()) |
|
|
face_path = os.path.join(TMP_DIR, f"{uid}_face.jpg") |
|
|
eye_path = os.path.join(TMP_DIR, f"{uid}_eye.jpg") |
|
|
|
|
|
logger.info("Reading and saving images...") |
|
|
face_bytes = await face_image.read() |
|
|
eye_bytes = await eye_image.read() |
|
|
|
|
|
with open(face_path, "wb") as f: |
|
|
f.write(face_bytes) |
|
|
with open(eye_path, "wb") as f: |
|
|
f.write(eye_bytes) |
|
|
logger.info("✅ Images saved: %d + %d bytes", len(face_bytes), len(eye_bytes)) |
|
|
|
|
|
|
|
|
vlm_features, vlm_raw = await asyncio.to_thread(call_vlm, face_path, eye_path) |
|
|
|
|
|
|
|
|
llm_input = vlm_raw if vlm_raw else (vlm_features if vlm_features else "{}") |
|
|
structured_risk = await asyncio.to_thread(call_llm, llm_input, use_fallback_on_error=True) |
|
|
|
|
|
logger.info("=" * 80) |
|
|
logger.info("✅ GET VITALS COMPLETED") |
|
|
logger.info("=" * 80) |
|
|
|
|
|
return { |
|
|
"vlm_features": vlm_features, |
|
|
"vlm_raw": vlm_raw, |
|
|
"structured_risk": structured_risk, |
|
|
"using_fallback": structured_risk.get("fallback_mode", False), |
|
|
"using_gcs": gcs_bucket is not None |
|
|
} |
|
|
|
|
|
except Exception as e: |
|
|
logger.exception("❌ Get vitals failed") |
|
|
error_msg = str(e).lower() |
|
|
|
|
|
if "quota" in error_msg or "gpu" in error_msg: |
|
|
raise HTTPException( |
|
|
status_code=503, |
|
|
detail="AI service at capacity. Please try again in a few minutes." |
|
|
) |
|
|
|
|
|
raise HTTPException( |
|
|
status_code=500, |
|
|
detail="Unable to process images. Please try again." |
|
|
) |
|
|
|
|
|
@app.post("/api/v1/get-vitals/{screening_id}") |
|
|
async def get_vitals_for_screening(screening_id: str): |
|
|
"""Re-run VLM + LLM on existing screening""" |
|
|
logger.info("GET VITALS FOR EXISTING SCREENING: %s", screening_id) |
|
|
|
|
|
if screening_id not in screenings_db: |
|
|
raise HTTPException(status_code=404, detail="Screening not found") |
|
|
|
|
|
entry = screenings_db[screening_id] |
|
|
face_path = entry.get("face_image_path") |
|
|
eye_path = entry.get("eye_image_path") |
|
|
|
|
|
if not (face_path and os.path.exists(face_path) and eye_path and os.path.exists(eye_path)): |
|
|
raise HTTPException(status_code=400, detail="Images missing") |
|
|
|
|
|
try: |
|
|
vlm_features, vlm_raw = await asyncio.to_thread(call_vlm, face_path, eye_path) |
|
|
llm_input = vlm_raw if vlm_raw else (vlm_features if vlm_features else "{}") |
|
|
structured_risk = await asyncio.to_thread(call_llm, llm_input, use_fallback_on_error=True) |
|
|
|
|
|
entry.setdefault("ai_results", {}).update({ |
|
|
"vlm_features": vlm_features, |
|
|
"vlm_raw": vlm_raw, |
|
|
"structured_risk": structured_risk, |
|
|
"last_vitals_run": datetime.utcnow().isoformat() + "Z", |
|
|
"using_fallback": structured_risk.get("fallback_mode", False) |
|
|
}) |
|
|
|
|
|
logger.info("✅ Get vitals completed for screening: %s", screening_id) |
|
|
|
|
|
return { |
|
|
"screening_id": screening_id, |
|
|
"vlm_features": vlm_features, |
|
|
"vlm_raw": vlm_raw, |
|
|
"structured_risk": structured_risk, |
|
|
"using_fallback": structured_risk.get("fallback_mode", False) |
|
|
} |
|
|
|
|
|
except Exception as e: |
|
|
logger.exception("❌ Get vitals for screening failed") |
|
|
raise HTTPException(status_code=500, detail=str(e)) |
|
|
|
|
|
if __name__ == "__main__": |
|
|
import uvicorn |
|
|
logger.info("=" * 80) |
|
|
logger.info("STARTING FASTAPI SERVER") |
|
|
logger.info("=" * 80) |
|
|
uvicorn.run("app_with_detailed_logs:app", host="0.0.0.0", port=7860, reload=False) |