import gradio as gr import numpy as np import math import json import urllib.request import os from PIL import Image import tensorflow as tf print("TensorFlow version:", tf.__version__) MODEL_PATH = "/tmp/face_landmark.tflite" MODEL_URL = "https://storage.googleapis.com/mediapipe-assets/face_landmark.tflite" if not os.path.exists(MODEL_PATH): print("Downloading face landmark model...") urllib.request.urlretrieve(MODEL_URL, MODEL_PATH) print("Downloaded.") landmark_interp = tf.lite.Interpreter(model_path=MODEL_PATH) landmark_interp.allocate_tensors() lm_in = landmark_interp.get_input_details() lm_out = landmark_interp.get_output_details() LM_SIZE = (lm_in[0]['shape'][2], lm_in[0]['shape'][1]) print(f"Model input size: {LM_SIZE}, outputs: {len(lm_out)}") print("Model ready.") def detect_face_crop(image_pil): try: import cv2 img = np.array(image_pil.convert("RGB")) gray = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY) h, w = img.shape[:2] cascade = cv2.CascadeClassifier( cv2.data.haarcascades + "haarcascade_frontalface_default.xml") faces = cascade.detectMultiScale( gray, scaleFactor=1.1, minNeighbors=4, minSize=(60,60)) if len(faces) > 0: faces = sorted(faces, key=lambda f: f[2]*f[3], reverse=True) x, y, fw, fh = faces[0] pad = int(max(fw, fh) * 0.35) x1 = max(0, x - pad); y1 = max(0, y - pad) x2 = min(w, x + fw + pad); y2 = min(h, y + fh + pad) return image_pil.crop((x1,y1,x2,y2)), x1/w, y1/h, x2/w, y2/h except Exception as e: print(f"CV2: {e}") m = 0.08 iw, ih = image_pil.size return (image_pil.crop((int(iw*m),int(ih*m), int(iw*(1-m)),int(ih*(1-m)))), m, m, 1-m, 1-m) def analyse_face(image): try: orig_w, orig_h = image.size crop, cx1, cy1, cx2, cy2 = detect_face_crop(image) crop_r = crop.convert("RGB").resize(LM_SIZE, Image.LANCZOS) inp = np.array(crop_r, dtype=np.float32)[np.newaxis] / 255.0 landmark_interp.set_tensor(lm_in[0]['index'], inp) landmark_interp.invoke() raw = landmark_interp.get_tensor(lm_out[0]['index']).reshape(-1, 3) if len(lm_out) > 1: conf = float(landmark_interp.get_tensor(lm_out[1]['index']).flatten()[0]) print(f"Conf: {conf:.3f}") if conf < 0.15: return json.dumps({"error": "No face detected. Please upload a clear photo."}) lm_w, lm_h = LM_SIZE lm = [] for pt in raw: lm.append({ "x": float((pt[0]/lm_w) * (cx2-cx1) + cx1), "y": float((pt[1]/lm_h) * (cy2-cy1) + cy1), "z": float(pt[2]/lm_w) }) W, H = orig_w, orig_h def d(a, b): return math.sqrt( ((lm[a]['x']-lm[b]['x'])*W)**2 + ((lm[a]['y']-lm[b]['y'])*H)**2) # ── VERIFIED landmark indices for face_landmark.tflite 468-pt model ── # These are confirmed stable across faces: # 10 = forehead centre top # 152 = chin bottom # 1 = nose tip # 4 = nose lower # 61 = left mouth corner # 291 = right mouth corner # 172 = left jaw # 397 = right jaw # 17 = lower lip centre # 0 = upper lip centre # Face reference: mouth width (very stable) mouth_w = d(61, 291) if mouth_w < 1: return json.dumps({"error": "Face too small. Please use a closer photo."}) # Face height measurements face_h = d(10, 152) # forehead to chin nose_to_chin = d(1, 152) # nose tip to chin eye_to_chin = d(6, 152) # mid nose-bridge to chin (idx 6 = mid face) # Proportional ratios (normalised by face height - scale independent) lower_ratio = nose_to_chin / face_h # increases with age as jowls descend # Jaw width vs mouth width ratio (widens/softens with age) jaw_w = d(172, 397) jaw_ratio = jaw_w / mouth_w # higher = more jowling # Forehead z-depth variance (texture proxy for wrinkles) fh_idx = [10,109,67,103,54,21,162,127,338,297,332,284,251,389,356] z_vals = [lm[i]['z'] for i in fh_idx if i < len(lm)] z_mean = sum(z_vals)/len(z_vals) z_var = sum((z-z_mean)**2 for z in z_vals)/len(z_vals) texture = math.sqrt(abs(z_var)) * 100 # Lip thinning proxy (lip height vs mouth width) lip_h = d(0, 17) # upper to lower lip lip_ratio = lip_h / mouth_w # decreases with age # Print diagnostics print(f"mouth_w={mouth_w:.0f}px face_h={face_h:.0f}px") print(f"lower_ratio={lower_ratio:.3f} jaw_ratio={jaw_ratio:.3f}") print(f"texture={texture:.3f} lip_ratio={lip_ratio:.3f}") # ── AGE ESTIMATION ── # Calibrated from ground truth: 34yo male → lower_ratio=0.340 # Linear fit through 4 age anchors (20/34/50/65yo): # age = -92.5 + 375 * lower_ratio # lower_ratio is clamped to [0.26, 0.48] to prevent extremes # Texture (z-depth) removed — unreliable across photos/lighting lr_clamped = max(0.26, min(0.48, lower_ratio)) age_raw = -92.5 + 375.0 * lr_clamped age_mid = max(18, min(68, round(age_raw))) age_low = max(18, age_mid - 5) age_high = min(75, age_mid + 5) age_range = f"{age_low}\u2013{age_high}" print(f"age_raw={age_raw:.1f} age_mid={age_mid}") # ── WRINKLE SCORE ── # Use age-mid as primary driver (reliable), texture as small modifier # Texture z-depth varies with lighting/model noise, so weight it lightly texture_capped = min(texture, 0.15) # cap to prevent domination wrinkle = round(max(1.0, min(9.9, 1.0 + (age_mid - 18) * 0.16 + texture_capped * 8)), 1) # ── ELASTICITY ── # Cheek sag: distance from nose tip to jaw corner # normalised by face height cheek_l = d(1, 172) / face_h cheek_r = d(1, 397) / face_h cheek_sag = (cheek_l + cheek_r) / 2 elasticity = round(max(1.0, min(9.9, 10 - (cheek_sag - 0.55) * 20 - (age_mid - 18) * 0.085)), 1) # ── JAWLINE ── version 2 jaw_pts = [172,136,150,149,176,148,152,377,400,378,379,365,397] jaw_dev = 0.0 for i in range(1, len(jaw_pts)-1): p, c, n = jaw_pts[i-1], jaw_pts[i], jaw_pts[i+1] ax = (lm[c]['x']-lm[p]['x'])*W; ay = (lm[c]['y']-lm[p]['y'])*H bx = (lm[n]['x']-lm[c]['x'])*W; by = (lm[n]['y']-lm[c]['y'])*H jaw_dev += abs(ax*by - ay*bx) / (mouth_w**2) jaw_dev /= len(jaw_pts) jawline = round(max(1.0, min(9.9, 9.5 - jaw_dev * 0.5 - (age_mid - 18) * 0.08)), 1) age_factor = round(max(0.0, min(1.0, (age_mid-18)/50)), 3) years_younger = max(3, round(age_factor * 13 + 2)) print(f"Scores: wrinkle={wrinkle} elasticity={elasticity} jawline={jawline}") return json.dumps({ "age_range": age_range, "age_mid": age_mid, "wrinkle": wrinkle, "elasticity": elasticity, "jawline": jawline, "years_younger": years_younger, "age_factor": age_factor, "landmarks": lm, "image_width": W, "image_height": H }) except Exception as e: import traceback return json.dumps({"error": str(e), "trace": traceback.format_exc()}) iface = gr.Interface( fn=analyse_face, inputs=gr.Image(type="pil", label="Upload Face Photo"), outputs=gr.Textbox(label="Analysis JSON"), title="AgeAI Face Analysis", description="Returns facial landmark data and ageing scores as JSON.", api_name="predict" ) iface.launch()