Spaces:
Running
Running
| 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() |