dpv007 commited on
Commit
45ec08d
·
verified ·
1 Parent(s): 400347c

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +175 -144
app.py CHANGED
@@ -3,18 +3,17 @@
3
  Elderly HealthWatch AI Backend (FastAPI)
4
  Pipeline:
5
  - receive images
6
- - run VLM (remote gradio / chat_fn) -> JSON feature vector + raw text + meta
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 variant:
14
- * logs raw VLM responses,
15
- * always returns raw VLM output in API responses,
16
- * extracts JSON from VLM via regex when possible, and
17
- * sends only the face image to the VLM (not the eye image).
18
  """
19
 
20
  import io
@@ -26,6 +25,7 @@ import logging
26
  import traceback
27
  import re
28
  import time
 
29
  from typing import Dict, Any, Optional, Tuple
30
  from datetime import datetime
31
 
@@ -36,7 +36,10 @@ from PIL import Image
36
  import numpy as np
37
  import cv2 # opencv-python-headless expected installed
38
 
39
- # Optional gradio client (for VLM + LLM calls)
 
 
 
40
  try:
41
  from gradio_client import Client, handle_file # type: ignore
42
  GRADIO_AVAILABLE = True
@@ -246,156 +249,193 @@ def extract_json_via_regex(raw_text: str) -> Dict[str, Any]:
246
  return out
247
 
248
  # -----------------------
249
- # Gradio / VLM helper (sends only face image, returns meta)
250
  # -----------------------
251
- def get_gradio_client_for_space(space: str) -> Client:
252
- if not GRADIO_AVAILABLE:
253
- raise RuntimeError("gradio_client not installed in this environment. Add gradio_client to requirements.txt.")
254
- if HF_TOKEN:
255
- return Client(space, hf_token=HF_TOKEN)
256
- return Client(space)
257
-
258
  def run_vlm_and_get_features(face_path: str, eye_path: Optional[str] = None, prompt: Optional[str] = None,
259
  raise_on_file_delivery_failure: bool = False
260
  ) -> Tuple[Optional[Dict[str, Any]], str, Dict[str, Any]]:
261
  """
262
- Synchronous call to remote VLM (gradio /chat_fn). Sends ONLY the face image file.
263
- Returns tuple: (parsed_features_dict_or_None, raw_text_response_str, meta)
264
- meta includes:
265
- - vlm_file_delivery_ok (bool) # expects ≥1 file acknowledged (face)
266
- - vlm_files_seen (int or None)
267
- - vlm_raw_len (int)
268
- - vlm_out_object (short repr)
 
 
 
269
  """
270
  prompt = prompt or DEFAULT_VLM_PROMPT
271
 
272
-
273
  if not os.path.exists(face_path):
274
  raise FileNotFoundError(f"Face image not found at: {face_path}")
275
- if not os.path.exists(eye_path):
276
- raise FileNotFoundError(f"Eye image not found at: {eye_path}")
277
-
278
- face_size = os.path.getsize(face_path)
279
- eye_size = os.path.getsize(eye_path)
280
- logger.info(f"VLM input files - Face: {face_size} bytes, Eye: {eye_size} bytes")
281
-
282
- if face_size == 0 or eye_size == 0:
283
- raise ValueError("One or both images are empty (0 bytes)")
284
-
285
- if not GRADIO_AVAILABLE:
286
- raise RuntimeError("gradio_client not available in this environment.")
287
-
288
- client = get_gradio_client_for_space(GRADIO_VLM_SPACE)
289
-
290
- # Verify files can be opened as images
291
- try:
292
- Image.open(face_path).verify()
293
- Image.open(eye_path).verify()
294
- logger.info("Both images verified as valid")
295
- except Exception as e:
296
- raise ValueError(f"Invalid image file(s): {e}")
297
-
298
- message = {"text": prompt, "files": [handle_file(face_path), handle_file(eye_path)]}
299
-
300
- logger.info(f"Calling VLM with message structure: text={len(prompt)} chars, files=2")
301
- client = get_gradio_client_for_space(GRADIO_VLM_SPACE)
302
- # NOTE: only send face image to the Space
303
 
304
- message = {"text": prompt, "files": [handle_file(face_path)]}
 
 
 
 
305
 
306
- meta: Dict[str, Any] = {"vlm_file_delivery_ok": False, "vlm_files_seen": None, "vlm_raw_len": 0, "vlm_out_object": None}
 
 
307
 
308
- # SINGLE CALL (no retries)
309
- try:
310
- logger.info("Calling VLM Space %s with 1 file (face only)", GRADIO_VLM_SPACE)
311
- result = client.predict(message=message, history=[], api_name="/chat_fn")
312
- except Exception as e:
313
- logger.exception("VLM call failed (no retries)")
314
- raise RuntimeError(f"VLM call failed: {e}")
 
 
315
 
316
- # Normalize result
317
- raw_text = ""
318
- out = None
319
- if not result:
320
- logger.warning("VLM returned empty result object")
321
  else:
322
- if isinstance(result, (list, tuple)):
323
- out = result[0]
324
- elif isinstance(result, dict):
325
- out = result
326
  else:
327
- out = {"text": str(result)}
328
 
329
- text_out = out.get("text") or out.get("output") or ""
330
- raw_text = text_out or ""
331
- meta["vlm_raw_len"] = len(raw_text or "")
332
- try:
333
- meta["vlm_out_object"] = str(out)[:2000]
334
- except Exception:
335
- meta["vlm_out_object"] = "<unreprable>"
336
 
337
- logger.info("VLM response object (debug snippet): %s", meta["vlm_out_object"])
 
 
 
 
 
 
 
 
 
 
338
 
339
- # --- Check whether the remote acknowledged receiving files (expect 1) ---
340
- files_seen = None
341
  try:
342
- if isinstance(out, dict):
343
- for key in ("files", "output_files", "files_sent", "uploaded_files", "received_files"):
344
- if key in out and isinstance(out[key], (list, tuple)):
345
- files_seen = len(out[key])
346
- break
347
 
348
- if files_seen is None and raw_text:
349
- ext_matches = re.findall(r"\.(?:jpg|jpeg|png|bmp|gif)\b", raw_text, flags=re.IGNORECASE)
350
- if ext_matches:
351
- files_seen = len(ext_matches)
352
- else:
353
- matches = re.findall(r"\b(?:uploaded|received|file)\b", raw_text, flags=re.IGNORECASE)
354
- if matches:
355
- files_seen = max(1, len(matches))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
356
 
357
- meta["vlm_files_seen"] = files_seen
358
- meta["vlm_file_delivery_ok"] = (files_seen is not None and files_seen >= 1)
359
- except Exception:
360
- meta["vlm_files_seen"] = None
361
- meta["vlm_file_delivery_ok"] = False
 
362
 
363
- if raise_on_file_delivery_failure and not meta["vlm_file_delivery_ok"]:
364
- logger.error("VLM did not acknowledge receiving the face file. meta=%s", meta)
365
- raise RuntimeError("VLM Space did not acknowledge receiving the face image")
366
 
367
- # Log raw VLM output for debugging/auditing
368
- logger.info("VLM raw output (length=%d):\n%s", len(raw_text or ""), (raw_text[:1000] + "...") if raw_text and len(raw_text) > 1000 else (raw_text or "<EMPTY>"))
369
 
370
- # Try to parse JSON first (fast path)
371
- parsed_features = None
372
- try:
373
- parsed_features = json.loads(raw_text) if raw_text and raw_text.strip() else None
374
- if parsed_features is not None and not isinstance(parsed_features, dict):
375
- parsed_features = None
376
- except Exception:
377
- parsed_features = None
 
 
 
 
 
 
 
 
 
 
378
 
379
- # If json.loads failed or returned None, try regex-based extraction
380
- if parsed_features is None and raw_text and raw_text.strip():
381
- try:
382
- parsed_features = extract_json_via_regex(raw_text)
383
- logger.info("VLM regex-extracted features:\n%s", json.dumps(parsed_features, indent=2, ensure_ascii=False))
384
- except Exception as e:
385
- logger.info("VLM regex extraction failed or found nothing: %s", str(e))
386
- parsed_features = None
387
 
388
- if parsed_features is None:
389
- logger.info("VLM parsed features: None (will fallback to sending '{}' or raw string to LLM).")
390
- else:
391
- logger.info("VLM parsed features (final): %s", json.dumps(parsed_features, ensure_ascii=False))
392
 
393
- # Always return parsed_features (or None), raw_text (string), and meta dict
394
- return parsed_features, (raw_text or ""), meta
 
 
 
 
395
 
396
  # -----------------------
397
  # Gradio / LLM helper (defensive, with retry + clamps)
398
  # -----------------------
 
 
 
 
 
 
 
399
  def run_llm_on_vlm(vlm_features_or_raw: Any,
400
  max_new_tokens: int = 1024,
401
  temperature: float = 0.0,
@@ -550,16 +590,6 @@ def run_llm_on_vlm(vlm_features_or_raw: Any,
550
 
551
  return parsed
552
 
553
- except AppError as app_e:
554
- logger.exception("LLM AppError (remote validation failed) on attempt %d: %s", attempt, str(app_e))
555
- last_exc = app_e
556
- if attempt == 1:
557
- predict_kwargs["temperature"] = 0.2
558
- predict_kwargs["max_new_tokens"] = float(512)
559
- logger.info("Retrying LLM call with temperature=0.2 and max_new_tokens=512")
560
- continue
561
- else:
562
- raise RuntimeError(f"LLM call failed (AppError): {app_e}")
563
  except Exception as e:
564
  logger.exception("LLM call failed on attempt %d: %s", attempt, str(e))
565
  last_exc = e
@@ -590,9 +620,10 @@ async def health_check():
590
  return {
591
  "status": "healthy",
592
  "detector": impl,
593
- "vlm_available": GRADIO_AVAILABLE,
594
  "vlm_space": GRADIO_VLM_SPACE,
595
- "llm_space": LLM_GRADIO_SPACE
 
596
  }
597
 
598
  @app.post("/api/v1/validate-eye-photo")
@@ -624,7 +655,7 @@ async def validate_eye_photo(image: UploadFile = File(...)):
624
  is_valid = eye_openness_score >= 0.3
625
  return {"valid": bool(is_valid), "face_detected": True, "eye_openness_score": round(eye_openness_score, 2),
626
  "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.",
627
- "message_hindi": "फोटो अच्छी है! आंखें ठीक से खुली हैं।" if is_valid else "आंखें बंद या आंशिक रूप से बंद दिखाई दे रही हैं। कृपया अपनी आंखें चौड़ी खोलें और पुनः प्रयास करें",
628
  "eye_landmarks": {"left_eye": left_eye, "right_eye": right_eye}}
629
  except Exception:
630
  traceback.print_exc()
@@ -648,7 +679,7 @@ async def validate_eye_photo(image: UploadFile = File(...)):
648
  is_valid = eye_openness_score >= 0.3
649
  return {"valid": bool(is_valid), "face_detected": True, "eye_openness_score": round(eye_openness_score, 2),
650
  "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.",
651
- "message_hindi": "फोटो अच्छी है! आंखें ठीक से खुली हैं।" if is_valid else "आंखें बंद या आंशिक रूप से बंद दिखाई दे रही हैं। कृपया अपनी आंखें चौड़ी खोलें और पुनः प्रयास करें",
652
  "eye_landmarks": {"left_eye": left_eye, "right_eye": right_eye}}
653
 
654
  if isinstance(mtcnn, dict) and mtcnn.get("impl") == "opencv":
@@ -675,7 +706,7 @@ async def validate_eye_photo(image: UploadFile = File(...)):
675
  left_eye = {"x": cx, "y": cy}
676
  return {"valid": bool(is_valid), "face_detected": True, "eye_openness_score": round(eye_openness_score, 2),
677
  "message_english": "Photo looks good! Eyes are detected." if is_valid else "Eyes not detected. Please open your eyes wide and try again.",
678
- "message_hindi": "फोटो अच्छी है! आंखें मिलीं।" if is_valid else "आंखें नहीं मिलीं। कृपया अपनी आंखें चौड़ी खोलें और पुनः प्रयास करें",
679
  "eye_landmarks": {"left_eye": left_eye, "right_eye": right_eye}}
680
  except Exception:
681
  traceback.print_exc()
@@ -777,7 +808,8 @@ async def get_vitals_from_upload(
777
  Note: VLM will receive only the face image (not the eye image).
778
  """
779
  if not GRADIO_AVAILABLE:
780
- raise HTTPException(status_code=500, detail="VLM/LLM client not available in this deployment.")
 
781
 
782
  # save files to a temp directory
783
  try:
@@ -888,8 +920,6 @@ class ImageUrls(BaseModel):
888
  face_image_url: HttpUrl
889
  eye_image_url: HttpUrl
890
 
891
- import httpx # make sure to add httpx to requirements
892
-
893
  # helper: download URL to file with safety checks
894
  async def download_image_to_path(url: str, dest_path: str, max_bytes: int = 5_000_000, timeout_seconds: int = 10) -> None:
895
  """
@@ -931,7 +961,8 @@ async def get_vitals_from_urls(payload: ImageUrls = Body(...)):
931
  Body: { "face_image_url": "...", "eye_image_url": "..." }
932
  """
933
  if not GRADIO_AVAILABLE:
934
- raise HTTPException(status_code=500, detail="VLM/LLM client not available in this deployment.")
 
935
 
936
  # prepare tmp paths
937
  try:
 
3
  Elderly HealthWatch AI Backend (FastAPI)
4
  Pipeline:
5
  - receive images
6
+ - run VLM (remote HF Space via gradio_api/call/chat style) -> raw text + meta
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 for LLM calls
12
+ - Add httpx to requirements.txt for VLM POST/GET flow
13
  - If VLM/LLM Spaces are private, set HF_TOKEN in the environment for authentication.
14
  - This variant:
15
+ * sends the face image to the HF Space using the POST/GET event flow
16
+ * returns raw VLM output and meta (no VLM-side JSON extraction)
 
 
17
  """
18
 
19
  import io
 
25
  import traceback
26
  import re
27
  import time
28
+ import base64
29
  from typing import Dict, Any, Optional, Tuple
30
  from datetime import datetime
31
 
 
36
  import numpy as np
37
  import cv2 # opencv-python-headless expected installed
38
 
39
+ # HTTP client for the POST/GET event style VLM calls
40
+ import httpx
41
+
42
+ # Optional gradio client (for LLM calls)
43
  try:
44
  from gradio_client import Client, handle_file # type: ignore
45
  GRADIO_AVAILABLE = True
 
249
  return out
250
 
251
  # -----------------------
252
+ # VLM helper using HF Spaces POST/GET event flow (gradio_api/call/chat)
253
  # -----------------------
 
 
 
 
 
 
 
254
  def run_vlm_and_get_features(face_path: str, eye_path: Optional[str] = None, prompt: Optional[str] = None,
255
  raise_on_file_delivery_failure: bool = False
256
  ) -> Tuple[Optional[Dict[str, Any]], str, Dict[str, Any]]:
257
  """
258
+ VLM caller using the HF Spaces 'gradio_api/call/chat' style:
259
+ 1) POST -> returns an EVENT_ID
260
+ 2) GET /gradio_api/call/chat/{EVENT_ID} -> fetch result
261
+
262
+ This function:
263
+ - Loads face image, encodes as base64 and embeds in JSON payload as a single file
264
+ - POSTs to the Space endpoint to create an event
265
+ - GETs the event result and extracts text/output
266
+ - Returns (parsed_features_or_None, raw_text, meta)
267
+ NOTE: The function returns parsed_features=None (no JSON extraction here) and raw_text for LLM downstream.
268
  """
269
  prompt = prompt or DEFAULT_VLM_PROMPT
270
 
 
271
  if not os.path.exists(face_path):
272
  raise FileNotFoundError(f"Face image not found at: {face_path}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
273
 
274
+ # Read and base64-encode the face image for embedding in JSON
275
+ with open(face_path, "rb") as f:
276
+ face_bytes = f.read()
277
+ if not face_bytes:
278
+ raise ValueError("Face image is empty (0 bytes)")
279
 
280
+ face_b64 = base64.b64encode(face_bytes).decode("ascii")
281
+ # prefix with MIME type (assume jpeg)
282
+ face_data_uri = f"data:image/jpeg;base64,{face_b64}"
283
 
284
+ # Build the JSON payload consistent with gradio multimodal style:
285
+ payload = {
286
+ "data": [
287
+ {
288
+ "text": prompt,
289
+ "files": [face_data_uri]
290
+ }
291
+ ]
292
+ }
293
 
294
+ # Prepare endpoint(s)
295
+ SPACE_HOST = os.getenv("VLM_SPACE_HOST") # optional full host override
296
+ if SPACE_HOST:
297
+ base_url = SPACE_HOST.rstrip("/")
 
298
  else:
299
+ # infer from GRADIO_VLM_SPACE if it's of form "owner/space-name"
300
+ # Many public HF spaces also map to {owner}-{space}.hf.space
301
+ if "/" in GRADIO_VLM_SPACE:
302
+ base_url = f"https://{GRADIO_VLM_SPACE.replace('/', '-')}.hf.space"
303
  else:
304
+ base_url = f"https://{GRADIO_VLM_SPACE}.hf.space"
305
 
306
+ post_url = f"{base_url}/gradio_api/call/chat"
307
+ get_url_template = f"{base_url}/gradio_api/call/chat/{{event_id}}"
 
 
 
 
 
308
 
309
+ headers = {"Content-Type": "application/json"}
310
+ if HF_TOKEN:
311
+ headers["Authorization"] = f"Bearer {HF_TOKEN}"
312
+
313
+ meta: Dict[str, Any] = {
314
+ "vlm_file_delivery_ok": False,
315
+ "vlm_files_seen": None,
316
+ "vlm_raw_len": 0,
317
+ "vlm_out_object": None,
318
+ "post_url": post_url
319
+ }
320
 
 
 
321
  try:
322
+ logger.info("VLM POST -> %s (payload text len=%d, files=1)", post_url, len(prompt))
323
+ with httpx.Client(timeout=30.0) as client:
324
+ resp = client.post(post_url, headers=headers, json=payload)
325
+ resp.raise_for_status()
 
326
 
327
+ # Try to robustly extract an event id from the POST response
328
+ event_id = None
329
+ try:
330
+ rj = resp.json()
331
+ except Exception:
332
+ rj = {}
333
+
334
+ if isinstance(rj, dict):
335
+ event_id = rj.get("event_id") or rj.get("id") or rj.get("job")
336
+ if not event_id:
337
+ # try to extract using regex from resp.text
338
+ m = re.search(r'"([^"]{8,})"', resp.text or "")
339
+ if m:
340
+ event_id = m.group(1)
341
+ if not event_id:
342
+ parts = re.split(r'"', resp.text or "")
343
+ if len(parts) >= 5:
344
+ event_id_candidate = parts[3].strip()
345
+ if event_id_candidate:
346
+ event_id = event_id_candidate
347
+ if not event_id:
348
+ raise RuntimeError(f"Failed to obtain EVENT_ID from VLM POST response: {resp.text[:1000]}")
349
+
350
+ meta["event_id"] = event_id
351
+ logger.info("VLM event created: %s", event_id)
352
+
353
+ # Poll the GET result endpoint
354
+ get_url = get_url_template.format(event_id=event_id)
355
+ logger.info("Polling VLM event result at %s", get_url)
356
+
357
+ max_polls = 6
358
+ poll_delay = 0.5
359
+ final_text = ""
360
+ last_response_json = None
361
+ for attempt in range(max_polls):
362
+ r2 = client.get(get_url, headers=headers, timeout=30.0)
363
+ if r2.status_code == 204 or not (r2.text and r2.text.strip()):
364
+ time.sleep(poll_delay)
365
+ continue
366
+ try:
367
+ r2j = r2.json()
368
+ last_response_json = r2j
369
+ except Exception:
370
+ r2j = None
371
+
372
+ text_out = ""
373
+ if isinstance(r2j, dict):
374
+ if "data" in r2j and isinstance(r2j["data"], list) and len(r2j["data"]) > 0:
375
+ first = r2j["data"][0]
376
+ if isinstance(first, dict):
377
+ text_out = first.get("text") or first.get("output") or json.dumps(first)
378
+ elif isinstance(first, str):
379
+ text_out = first
380
+ text_out = text_out or r2j.get("text") or r2j.get("msg") or r2j.get("output", "") or ""
381
+ else:
382
+ text_out = r2.text or ""
383
 
384
+ if text_out and text_out.strip():
385
+ final_text = text_out
386
+ break
387
+ else:
388
+ time.sleep(poll_delay)
389
+ continue
390
 
391
+ if not final_text:
392
+ final_text = (r2.text or "").strip()
 
393
 
394
+ meta["vlm_raw_len"] = len(final_text)
395
+ meta["vlm_out_object"] = (final_text[:2000] + "...") if len(final_text) > 2000 else final_text
396
 
397
+ # Best-effort: detect whether server mentions receiving a file
398
+ files_seen = None
399
+ try:
400
+ if isinstance(last_response_json, dict):
401
+ for key in ("files", "output_files", "files_sent", "uploaded_files", "received_files"):
402
+ if key in last_response_json and isinstance(last_response_json[key], (list, tuple)):
403
+ files_seen = len(last_response_json[key])
404
+ break
405
+ if files_seen is None and final_text:
406
+ ext_matches = re.findall(r"\.(?:jpg|jpeg|png|bmp|gif)\b", final_text, flags=re.IGNORECASE)
407
+ if ext_matches:
408
+ files_seen = len(ext_matches)
409
+ else:
410
+ matches = re.findall(r"\b(?:uploaded|received|file)\b", final_text, flags=re.IGNORECASE)
411
+ if matches:
412
+ files_seen = max(1, len(matches))
413
+ except Exception:
414
+ files_seen = None
415
 
416
+ meta["vlm_files_seen"] = files_seen
417
+ meta["vlm_file_delivery_ok"] = (files_seen is not None and files_seen >= 1)
 
 
 
 
 
 
418
 
419
+ parsed_features = None
420
+ return parsed_features, (final_text or ""), meta
 
 
421
 
422
+ except httpx.HTTPStatusError as he:
423
+ logger.exception("VLM HTTP error")
424
+ raise RuntimeError(f"VLM http error: {he.response.status_code} {str(he)}")
425
+ except Exception as e:
426
+ logger.exception("VLM call (httpx) failed")
427
+ raise RuntimeError(f"VLM call failed: {e}")
428
 
429
  # -----------------------
430
  # Gradio / LLM helper (defensive, with retry + clamps)
431
  # -----------------------
432
+ def get_gradio_client_for_space(space: str) -> Client:
433
+ if not GRADIO_AVAILABLE:
434
+ raise RuntimeError("gradio_client not installed in this environment. Add gradio_client to requirements.txt.")
435
+ if HF_TOKEN:
436
+ return Client(space, hf_token=HF_TOKEN)
437
+ return Client(space)
438
+
439
  def run_llm_on_vlm(vlm_features_or_raw: Any,
440
  max_new_tokens: int = 1024,
441
  temperature: float = 0.0,
 
590
 
591
  return parsed
592
 
 
 
 
 
 
 
 
 
 
 
593
  except Exception as e:
594
  logger.exception("LLM call failed on attempt %d: %s", attempt, str(e))
595
  last_exc = e
 
620
  return {
621
  "status": "healthy",
622
  "detector": impl,
623
+ "vlm_available": True, # we use HTTP POST/GET for VLM
624
  "vlm_space": GRADIO_VLM_SPACE,
625
+ "llm_space": LLM_GRADIO_SPACE,
626
+ "gradio_client_for_llm": GRADIO_AVAILABLE
627
  }
628
 
629
  @app.post("/api/v1/validate-eye-photo")
 
655
  is_valid = eye_openness_score >= 0.3
656
  return {"valid": bool(is_valid), "face_detected": True, "eye_openness_score": round(eye_openness_score, 2),
657
  "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.",
658
+ "message_hindi": "फोटो अच्छी है! आंखें ठीक से खुली हैं।" if is_valid else "आंखें बंद या आंशिक रूप से बंद दिखाई दे रही हैं। कृपया अपनी आंखें चौड़ी खोलें और पुनः प्रयास करें",
659
  "eye_landmarks": {"left_eye": left_eye, "right_eye": right_eye}}
660
  except Exception:
661
  traceback.print_exc()
 
679
  is_valid = eye_openness_score >= 0.3
680
  return {"valid": bool(is_valid), "face_detected": True, "eye_openness_score": round(eye_openness_score, 2),
681
  "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.",
682
+ "message_hindi": "फोटो अच्छी है! आंखें ठीक से खुली हैं।" if is_valid else "आंखें बंद या आंशिक रूप से बंद दिखाई दे रही हैं। कृपया अपनी आंखें चौड़ी खोलें और पुनः प्रयास करें",
683
  "eye_landmarks": {"left_eye": left_eye, "right_eye": right_eye}}
684
 
685
  if isinstance(mtcnn, dict) and mtcnn.get("impl") == "opencv":
 
706
  left_eye = {"x": cx, "y": cy}
707
  return {"valid": bool(is_valid), "face_detected": True, "eye_openness_score": round(eye_openness_score, 2),
708
  "message_english": "Photo looks good! Eyes are detected." if is_valid else "Eyes not detected. Please open your eyes wide and try again.",
709
+ "message_hindi": "फोटो अच्छी है! आंखें मिलीं।" if is_valid else "आंखें नहीं मिलीं। कृपया अपनी आंखें चौड़ी खोलें और पुनः प्रयास करें",
710
  "eye_landmarks": {"left_eye": left_eye, "right_eye": right_eye}}
711
  except Exception:
712
  traceback.print_exc()
 
808
  Note: VLM will receive only the face image (not the eye image).
809
  """
810
  if not GRADIO_AVAILABLE:
811
+ # LLM call requires gradio_client to be installed. If not present, user must install it.
812
+ raise HTTPException(status_code=500, detail="LLM client (gradio_client) not available in this deployment.")
813
 
814
  # save files to a temp directory
815
  try:
 
920
  face_image_url: HttpUrl
921
  eye_image_url: HttpUrl
922
 
 
 
923
  # helper: download URL to file with safety checks
924
  async def download_image_to_path(url: str, dest_path: str, max_bytes: int = 5_000_000, timeout_seconds: int = 10) -> None:
925
  """
 
961
  Body: { "face_image_url": "...", "eye_image_url": "..." }
962
  """
963
  if not GRADIO_AVAILABLE:
964
+ # LLM call requires gradio_client to be installed
965
+ raise HTTPException(status_code=500, detail="LLM client (gradio_client) not available in this deployment.")
966
 
967
  # prepare tmp paths
968
  try: