Spaces:
Running
Running
File size: 8,028 Bytes
113a6d8 8a94720 2836940 2d415c4 925c9e5 2d415c4 f1af6fa 2d415c4 f1af6fa 2d415c4 f1af6fa 2d415c4 f1af6fa 91a1e8d 3fd1bee f1af6fa 91a1e8d f1af6fa 3fd1bee f1af6fa 91a1e8d 3fd1bee f1af6fa 3fd1bee 91a1e8d 3fd1bee 2836940 113a6d8 2d415c4 f1af6fa 3fd1bee f1af6fa 2d415c4 3fd1bee f1af6fa 3fd1bee f1af6fa 91a1e8d f1af6fa 3fd1bee 2d415c4 3fd1bee 113a6d8 3fd1bee 113a6d8 3fd1bee 2836940 113a6d8 3fd1bee 1b9136f 3fd1bee 91a1e8d 3fd1bee 91a1e8d 3fd1bee 91a1e8d 1b9136f 91a1e8d 1b9136f 3fd1bee df70ce4 91a1e8d 8a94720 91a1e8d 8a94720 3fd1bee 8a94720 91a1e8d 3fd1bee 8a94720 3fd1bee 113a6d8 3fd1bee 91a1e8d 113a6d8 8a94720 113a6d8 8a94720 91a1e8d 3fd1bee 113a6d8 2836940 113a6d8 3fd1bee 8a94720 113a6d8 8a94720 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 | 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() |