Rogerjs commited on
Commit
56b5de4
·
verified ·
1 Parent(s): eb4899a

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +298 -196
app.py CHANGED
@@ -1,265 +1,367 @@
1
  import gradio as gr
2
  from PIL import Image, ImageDraw
3
  import numpy as np
4
- import torch # Required by transformers
5
- from transformers import YolosImageProcessor, YolosForObjectDetection
6
  import mediapipe as mp
7
- import math # For distance calculations
8
 
9
- # --- Model Initialization (Load ONCE when the Space starts) ---
10
- # 1. Face Detection Model (YOLOS)
11
  print("Loading face detection model...")
12
- DETECTION_MODEL_NAME = "hustvl/yolos-tiny" # Smaller model, better for CPU
 
 
 
 
13
  try:
14
  face_image_processor = YolosImageProcessor.from_pretrained(DETECTION_MODEL_NAME)
15
  face_detection_model = YolosForObjectDetection.from_pretrained(DETECTION_MODEL_NAME)
16
- print("Face detection model loaded successfully.")
 
 
 
17
  except Exception as e:
18
- print(f"Error loading face detection model: {e}")
 
 
19
  face_image_processor = None
20
  face_detection_model = None
 
21
 
22
 
23
  # 2. Facial Landmark Model (MediaPipe Face Mesh)
24
  print("Initializing MediaPipe Face Mesh...")
25
  try:
26
  mp_face_mesh = mp.solutions.face_mesh
27
- # static_image_mode=True for processing individual images
28
- # max_num_faces=1 as we expect one primary face
29
- # min_detection_confidence for robustness
30
  face_mesh_detector = mp_face_mesh.FaceMesh(
31
  static_image_mode=True,
32
  max_num_faces=1,
33
- refine_landmarks=True, # Get more detailed landmarks (e.g., iris)
34
  min_detection_confidence=0.5)
 
 
35
  print("MediaPipe Face Mesh initialized successfully.")
36
  except Exception as e:
37
  print(f"Error initializing MediaPipe Face Mesh: {e}")
38
  face_mesh_detector = None
 
39
 
40
- # --- Helper Functions ---
41
 
 
42
  def detect_face_local(image_pil):
43
- if not face_image_processor or not face_detection_model:
44
- return None, "Face detection model not loaded."
45
 
46
  inputs = face_image_processor(images=image_pil, return_tensors="pt")
47
- outputs = face_detection_model(**inputs)
 
48
 
49
- # Post-process to get bounding boxes
50
- # target_sizes expects a tensor of [height, width]
51
- target_sizes = torch.tensor([image_pil.size[::-1]]) # PIL size is (width, height)
52
- results = face_image_processor.post_process_object_detection(outputs, threshold=0.7, target_sizes=target_sizes)[0]
53
 
54
  best_box = None
55
  max_score = 0
56
- person_label_id = None # YOLOS typically detects 'person' (label 0 in COCO usually)
57
-
58
- # Find the 'person' class ID if model config is available (or assume it if known)
59
- # For general YOLOS, 'person' is often label 0 if trained on COCO.
60
- # If your model has specific face labels, adjust this.
61
- # For hustvl/yolos-tiny, it's trained on COCO, where "person" is label 0.
62
- # Check model.config.id2label if needed
63
- # person_label_id = face_detection_model.config.label2id.get("person", 0) # More robust way
64
 
65
  for score, label, box in zip(results["scores"], results["labels"], results["boxes"]):
66
- # Assuming 'person' class is the one we want, or if it detects 'face' directly
67
- # For YOLOS, it's more likely to detect 'person'. We take the highest score 'person'.
68
- # You might need to adjust this if the model has a specific 'face' label
69
- if label == 0: # Assuming label 0 is 'person' for COCO-trained YOLOS
70
  if score > max_score:
71
  max_score = score
72
- best_box = box.tolist() # [xmin, ymin, xmax, ymax]
73
 
74
  if best_box:
75
- cropped_image = image_pil.crop(best_box)
76
- return cropped_image, None # No error message
77
- else:
78
- return None, "No face/person detected with sufficient confidence."
79
 
80
- def get_landmarks_mediapipe(image_pil):
81
- if not face_mesh_detector:
82
- return None, "MediaPipe Face Mesh not initialized."
 
83
 
84
- # MediaPipe expects BGR numpy array
85
- image_np = np.array(image_pil.convert('RGB'))
86
- image_rgb = image_np[:, :, ::-1].copy() # PIL RGB to CV2 BGR (not strictly needed here as MP handles RGB)
87
- # but good practice if using OpenCV functions later
88
- image_rgb_mp = np.array(image_pil.convert('RGB')) # MediaPipe prefers RGB
 
 
 
89
 
 
90
  results = face_mesh_detector.process(image_rgb_mp)
91
 
 
 
92
  if results.multi_face_landmarks:
93
- return results.multi_face_landmarks[0], None # Return landmarks for the first face
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
  else:
95
- return None, "Could not detect facial landmarks."
96
 
97
- def _distance(p1, p2):
98
- return math.sqrt((p1.x - p2.x)**2 + (p1.y - p2.y)**2 + (p1.z - p2.z)**2)
99
 
100
- def _distance_2d(p1, p2, img_width, img_height):
101
- # Convert normalized coordinates to pixel coordinates for more intuitive ratios
102
- x1, y1 = p1.x * img_width, p1.y * img_height
103
- x2, y2 = p2.x * img_width, p2.y * img_height
104
- return math.sqrt((x1 - x2)**2 + (y1 - y2)**2)
105
 
106
-
107
- def estimate_face_shape_from_landmarks(landmarks, img_width, img_height):
108
  if not landmarks:
109
- return "Unknown"
110
-
111
- # Key landmark indices for MediaPipe Face Mesh (468 landmarks total)
112
- # These are approximate and might need fine-tuning or using specific contour points
113
- # Forehead: e.g., landmark 10 (top of forehead)
114
- # Jaw: e.g., landmarks 172, 397 (jaw points), 152 (chin)
115
- # Cheekbones: e.g., landmarks 234, 454 (outer cheekbones) or 116, 345 (zygomatic arch)
116
- # Face Width: Widest points, often around cheekbones (e.g. 234 to 454)
117
- # Face Height: Top of forehead (e.g. 10) to chin (e.g. 152)
118
-
119
- # Example: Use specific points from standard MediaPipe landmark map
120
- # (https://github.com/google/mediapipe/blob/master/mediapipe/modules/face_geometry/data/canonical_face_model_uv_visualization.png)
121
-
122
- # Points for measurements (these are just examples, adjust as needed)
123
- # These indices are 0-based from the 468 landmarks
124
- # Check https://viz.mediapipe.dev/face_ όχιmesh_webgl_demo for interactive map
125
-
126
- # Face Height: Top of Forehead (10) to Chin (152)
127
- p_forehead_top = landmarks.landmark[10]
128
- p_chin = landmarks.landmark[152]
129
- face_height = _distance_2d(p_forehead_top, p_chin, img_width, img_height)
130
-
131
- # Face Width (approx at cheekbones): Left (234) to Right (454)
132
  p_cheek_left = landmarks.landmark[234]
133
  p_cheek_right = landmarks.landmark[454]
134
- face_width_cheek = _distance_2d(p_cheek_left, p_cheek_right, img_width, img_height)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
 
136
- # Forehead Width (approx temples): Left (70) to Right (300) - might be too wide, adjust
137
- # Or use points like 54 and 284 for a narrower forehead measure
138
- p_forehead_left = landmarks.landmark[54] # More like outer brow
139
- p_forehead_right = landmarks.landmark[284] # More like outer brow
140
- forehead_width = _distance_2d(p_forehead_left, p_forehead_right, img_width, img_height)
141
-
142
- # Jawline Width (approx): Point near jaw angle left (172) to right (397)
143
- # Or closer to chin base: 143 and 372
144
- p_jaw_left = landmarks.landmark[132] # Lower jaw points
145
- p_jaw_right = landmarks.landmark[361]
146
- jaw_width = _distance_2d(p_jaw_left, p_jaw_right, img_width, img_height)
147
-
148
- # Simple Heuristics (these are very basic and need refinement/testing)
149
- # Ratios are more reliable than absolute values due to image scale
 
 
 
 
 
150
 
151
- if face_height == 0 or face_width_cheek == 0: return "Unknown (measurement error)"
152
-
153
- ratio_h_w = face_height / face_width_cheek
154
-
155
- # Print measurements for debugging
156
- print(f"H: {face_height:.2f}, W_Cheek: {face_width_cheek:.2f}, W_Forehead: {forehead_width:.2f}, W_Jaw: {jaw_width:.2f}")
157
- print(f"Ratio H/W: {ratio_h_w:.2f}")
158
- print(f"Forehead/Cheek: {forehead_width/face_width_cheek if face_width_cheek else 0:.2f}")
159
- print(f"Jaw/Cheek: {jaw_width/face_width_cheek if face_width_cheek else 0:.2f}")
160
-
161
- # These rules are very basic and a starting point
162
- if ratio_h_w > 1.25: # Significantly longer than wide
163
- if forehead_width > jaw_width and jaw_width < face_width_cheek * 0.85 :
164
- return "Heart/Inverted Triangle" # Narrow chin
165
- return "Long/Oblong"
166
- elif ratio_h_w < 0.95: # Wider than tall or close to it
167
- return "Round/Square (Wide)" # Need more to differentiate round vs square (jaw angle)
168
- else: # Height and width are somewhat proportional (0.95 to 1.25)
169
- # Check relative widths of forehead, cheeks, jaw
170
- f_w = forehead_width
171
- c_w = face_width_cheek
172
- j_w = jaw_width
173
-
174
- if abs(f_w - c_w) < c_w * 0.1 and abs(c_w - j_w) < c_w * 0.1 and abs(f_w - j_w) < f_w * 0.1:
175
- # All widths are roughly similar
176
- return "Square" # More angular jaw typically
177
- elif c_w > f_w and c_w > j_w:
178
- return "Diamond" # Widest at cheeks
179
- elif f_w > c_w * 0.9 and f_w > j_w and j_w < c_w * 0.9: # Forehead prominent, jaw narrower
180
- return "Heart"
181
- elif f_w < c_w and j_w < c_w and abs(f_w - j_w) < f_w * 0.15 : # Forehead and jaw narrower than cheeks, but similar to each other
182
- return "Oval" # Often considered ideal, balanced
183
- else: # Default or fallback
184
- return "Oval/Round" # Difficult to distinguish without more rules
185
-
186
- return "Oval (Default)" # Fallback
187
-
188
-
189
- def get_hairstyle_suggestions(face_shape, gender="neutral"):
190
- # (Same suggestion dictionary as before - keep it for brevity)
191
- suggestions = {
192
- "Oval": {"hair": ["Most hairstyles work well.", "Layers or sleek bob."], "beard": ["Most beard styles.", "Classic full beard."]},
193
- "Oval (Default)": {"hair": ["Try versatile styles like layers or a textured crop.", "Side parts can be flattering."], "beard": ["A well-groomed stubble or a short boxed beard often works."]},
194
- "Long/Oblong": {"hair": ["Add width: Curls, waves, layers with side volume.", "Bangs can shorten face."], "beard": ["Fuller on cheeks: full beard, mutton chops."]},
195
- "Heart": {"hair": ["Add jawline volume: chin-length bobs, layered shoulder cuts.", "Side-swept bangs."], "beard": ["Fuller beards to add jaw width: Garibaldi."]},
196
- "Heart/Inverted Triangle": {"hair": ["Add jawline volume: chin-length bobs, layered shoulder cuts.", "Side-swept bangs for forehead."], "beard": ["Fuller beards to add jaw width: Garibaldi, full beard carefully shaped."]},
197
- "Square": {"hair": ["Softer styles, waves, curls. Texture to soften angles.", "Avoid sharp, geometric cuts."], "beard": ["Circle beard, rounded full beard."]},
198
- "Round/Square (Wide)": {"hair": ["Add height: pompadour, quiff. Layers, off-center parts.", "Avoid blunt bobs at chin."], "beard": ["For round: goatee, beard longer at chin. For square: soften jaw with rounded styles."]},
199
- "Diamond": {"hair": ["Soften forehead & jaw: chin bobs, shoulder length with layers.", "Side-swept fringe."], "beard": ["Fuller at chin, possibly some width at jaw but not cheeks: Balbo, shorter full beard."]},
200
- "Oval/Round": {"hair": ["Versatile. Add slight height or soft layers.", "Avoid overly round styles if aiming to balance roundness."], "beard": ["Many styles work. A neatly trimmed beard or a Van Dyke can be good."]},
201
  "Unknown": {"hair": ["Upload a clearer image for analysis."], "beard": ["Upload a clearer image for analysis."]},
202
- "Unknown (measurement error)": {"hair": ["Could not reliably measure face. Try a different pose or lighting."], "beard": ["Could not reliably measure face. Try a different pose or lighting."]},
 
203
  }
204
- if face_shape in suggestions:
205
- hair_sug = "\n".join([f"- {s}" for s in suggestions[face_shape]["hair"]])
206
- beard_sug = "\n".join([f"- {s}" for s in suggestions[face_shape]["beard"]])
207
- return f"**Haircut Suggestions for {face_shape} Face:**\n{hair_sug}\n\n**Beard Style Suggestions for {face_shape} Face:**\n{beard_sug}"
208
- return f"Could not determine suggestions for the estimated face shape: {face_shape}."
 
 
 
 
 
 
 
 
209
 
210
 
211
- def analyze_face_and_suggest(front_image_input, side_image_optional):
212
  if front_image_input is None:
213
- return None, "Please upload a front-facing photo.", ""
214
 
215
- # Gradio Image input is a NumPy array
216
- img_pil = Image.fromarray(front_image_input).convert("RGB") # Ensure RGB
217
 
218
- # 1. Detect Face (Local YOLOS model)
219
- cropped_face_pil, error_msg = detect_face_local(img_pil)
220
- if error_msg:
221
- return None, error_msg, ""
222
- if cropped_face_pil is None:
223
- return None, "Could not detect a face.", ""
224
 
225
- # 2. Get Facial Landmarks (MediaPipe)
226
- landmarks, error_msg_lm = get_landmarks_mediapipe(cropped_face_pil)
227
  if error_msg_lm:
228
- # If landmarks fail, still show cropped face but indicate no shape analysis
229
- return cropped_face_pil, f"Face detected. Error getting landmarks: {error_msg_lm}", "Cannot suggest hairstyles without landmark analysis."
230
-
231
- # For drawing landmarks (optional visualization)
232
- # cropped_face_with_landmarks_pil = cropped_face_pil.copy()
233
- # draw = ImageDraw.Draw(cropped_face_with_landmarks_pil)
234
- # for landmark in landmarks.landmark:
235
- # x = int(landmark.x * cropped_face_pil.width)
236
- # y = int(landmark.y * cropped_face_pil.height)
237
- # draw.ellipse((x-1, y-1, x+1, y+1), fill='red')
238
 
 
 
 
 
 
 
239
 
240
- # 3. Estimate Face Shape from Landmarks
241
- img_w, img_h = cropped_face_pil.size
242
- estimated_shape = estimate_face_shape_from_landmarks(landmarks, img_w, img_h)
243
-
244
- # --- Side profile (acknowledgement, no processing yet) ---
245
- side_info = "Side profile not uploaded or not yet processed."
246
- if side_image_optional is not None:
247
- side_info = "Side profile uploaded (analysis can be enhanced in future versions to refine shape)."
248
- # Potentially run detection + landmarks on side_image_optional here too
249
- # And combine information for a more robust `estimated_shape`
250
 
251
- # 4. Get Suggestions
252
- suggestions_text = get_hairstyle_suggestions(estimated_shape)
253
 
254
- return cropped_face_pil, f"Estimated Face Shape: **{estimated_shape}**\n{side_info}", suggestions_text
255
 
256
  # --- Gradio Interface ---
257
  with gr.Blocks(theme=gr.themes.Soft()) as demo:
258
- gr.Markdown("# ✂️ AI Hairstyle & Beard Suggester (Local Models) 🧔")
259
  gr.Markdown(
260
- "Upload a clear, front-facing photo. "
261
- "Optionally, upload a side profile (currently not used for analysis but can be added)."
262
- "\n*Disclaimer: This app uses local AI models for face detection and landmark-based shape estimation. Suggestions are based on general heuristics and may not be perfect.*"
263
  )
264
 
265
  with gr.Row():
@@ -268,20 +370,20 @@ with gr.Blocks(theme=gr.themes.Soft()) as demo:
268
  side_image_input = gr.Image(type="numpy", label="Side Profile Photo (Optional)", sources=["upload", "webcam"])
269
  submit_btn = gr.Button("Get Suggestions", variant="primary")
270
  with gr.Column(scale=2):
271
- output_image = gr.Image(label="Detected Face (or Cropped with Landmarks)")
272
- output_shape_info = gr.Markdown(label="Face Analysis")
273
  output_suggestions = gr.Markdown(label="Suggestions")
274
 
275
  submit_btn.click(
276
- analyze_face_and_suggest,
277
  inputs=[front_image_input, side_image_input],
278
- outputs=[output_image, output_shape_info, output_suggestions]
279
  )
280
- gr.Markdown("--- \n ### Note on Face Shape Estimation: \n The face shape estimation is based on ratios of distances between facial landmarks. The categories (Oval, Round, Square, etc.) and the rules to classify them are simplified. For more accurate results, a dedicated face shape classification model or more complex geometric analysis would be needed. The landmark points used are: \n - **Height:** Top of Forehead (MP Landmark 10) to Chin (MP 152) \n - **Cheek Width:** Left Cheek (MP 234) to Right Cheek (MP 454) \n - **Forehead Width:** Left Outer Brow (MP 54) to Right Outer Brow (MP 284) \n - **Jaw Width:** Left Lower Jaw (MP 132) to Right Lower Jaw (MP 361)")
281
 
282
 
283
  if __name__ == "__main__":
284
- if face_detection_model and face_mesh_detector: # Only launch if models loaded
285
  demo.launch()
286
  else:
287
- print("Gradio app not launched due to model loading errors.")
 
1
  import gradio as gr
2
  from PIL import Image, ImageDraw
3
  import numpy as np
4
+ import torch
5
+ from transformers import YolosImageProcessor, YolosForObjectDetection # Keep for YOLOS architecture
6
  import mediapipe as mp
7
+ import math
8
 
9
+ # --- Model Initialization ---
10
+ # 1. Face Detection Model (Switching to yolos-face if possible)
11
  print("Loading face detection model...")
12
+ # Try hustvl/yolos-face (specific for faces)
13
+ # If it fails, we can revert to hustvl/yolos-tiny (person detection)
14
+ DETECTION_MODEL_NAME = "hustvl/yolos-face"
15
+ # DETECTION_MODEL_NAME_FALLBACK = "hustvl/yolos-tiny" # If yolos-face fails
16
+
17
  try:
18
  face_image_processor = YolosImageProcessor.from_pretrained(DETECTION_MODEL_NAME)
19
  face_detection_model = YolosForObjectDetection.from_pretrained(DETECTION_MODEL_NAME)
20
+ # For yolos-face, the label for "face" is often 0. Check model.config.id2label if unsure.
21
+ # It seems like hustvl/yolos-face is also trained on a single class "face" (ID 0)
22
+ FACE_LABEL_ID = 0 # Assuming face is label 0 for this model
23
+ print(f"Face detection model ({DETECTION_MODEL_NAME}) loaded successfully.")
24
  except Exception as e:
25
+ print(f"Error loading {DETECTION_MODEL_NAME}: {e}. ")
26
+ # Fallback or error handling needed if you want to deploy with a working detector
27
+ # For now, we'll let it fail if the primary model doesn't load to highlight the issue.
28
  face_image_processor = None
29
  face_detection_model = None
30
+ FACE_LABEL_ID = -1 # Indicate model not loaded
31
 
32
 
33
  # 2. Facial Landmark Model (MediaPipe Face Mesh)
34
  print("Initializing MediaPipe Face Mesh...")
35
  try:
36
  mp_face_mesh = mp.solutions.face_mesh
 
 
 
37
  face_mesh_detector = mp_face_mesh.FaceMesh(
38
  static_image_mode=True,
39
  max_num_faces=1,
40
+ refine_landmarks=True,
41
  min_detection_confidence=0.5)
42
+ mp_drawing = mp.solutions.drawing_utils # For drawing landmarks
43
+ drawing_spec = mp_drawing.DrawingSpec(thickness=1, circle_radius=1, color=(0,255,0)) # Green dots
44
  print("MediaPipe Face Mesh initialized successfully.")
45
  except Exception as e:
46
  print(f"Error initializing MediaPipe Face Mesh: {e}")
47
  face_mesh_detector = None
48
+ mp_drawing = None
49
 
 
50
 
51
+ # --- Helper Functions ---
52
  def detect_face_local(image_pil):
53
+ if not face_image_processor or not face_detection_model or FACE_LABEL_ID == -1:
54
+ return None, "Face detection model not loaded or configured properly."
55
 
56
  inputs = face_image_processor(images=image_pil, return_tensors="pt")
57
+ with torch.no_grad(): # Important for inference
58
+ outputs = face_detection_model(**inputs)
59
 
60
+ target_sizes = torch.tensor([image_pil.size[::-1]])
61
+ results = face_image_processor.post_process_object_detection(outputs, threshold=0.8, target_sizes=target_sizes)[0] # Increased threshold
 
 
62
 
63
  best_box = None
64
  max_score = 0
 
 
 
 
 
 
 
 
65
 
66
  for score, label, box in zip(results["scores"], results["labels"], results["boxes"]):
67
+ if label == FACE_LABEL_ID: # Check for the specific 'face' label
 
 
 
68
  if score > max_score:
69
  max_score = score
70
+ best_box = box.tolist()
71
 
72
  if best_box:
73
+ # Add a small padding to the bounding box to ensure the whole face is included
74
+ padding_w = (best_box[2] - best_box[0]) * 0.1 # 10% padding width
75
+ padding_h = (best_box[3] - best_box[1]) * 0.1 # 10% padding height
 
76
 
77
+ xmin = max(0, best_box[0] - padding_w)
78
+ ymin = max(0, best_box[1] - padding_h)
79
+ xmax = min(image_pil.width, best_box[2] + padding_w)
80
+ ymax = min(image_pil.height, best_box[3] + padding_h)
81
 
82
+ cropped_image = image_pil.crop((xmin, ymin, xmax, ymax))
83
+ return cropped_image, None
84
+ else:
85
+ return None, "No face detected with sufficient confidence."
86
+
87
+ def get_landmarks_and_draw(image_pil):
88
+ if not face_mesh_detector or not mp_drawing:
89
+ return None, "MediaPipe Face Mesh not initialized for landmarks.", image_pil # Return original if no detector
90
 
91
+ image_rgb_mp = np.array(image_pil.convert('RGB'))
92
  results = face_mesh_detector.process(image_rgb_mp)
93
 
94
+ annotated_image_pil = image_pil.copy() # Start with a copy of the input PIL image
95
+
96
  if results.multi_face_landmarks:
97
+ landmarks = results.multi_face_landmarks[0] # First face
98
+
99
+ # Draw landmarks on the PIL image
100
+ # Convert PIL to NumPy array for drawing, then back to PIL
101
+ image_np_to_draw = np.array(annotated_image_pil)
102
+ for landmark in landmarks.landmark:
103
+ x = int(landmark.x * image_np_to_draw.shape[1])
104
+ y = int(landmark.y * image_np_to_draw.shape[0])
105
+ # Small green circle for each landmark (using PIL Draw directly for simplicity here)
106
+ # For more complex drawing, use mp_drawing.draw_landmarks on the numpy array
107
+
108
+ # Using mp_drawing.draw_landmarks for better visualization
109
+ mp_drawing.draw_landmarks(
110
+ image=image_np_to_draw,
111
+ landmark_list=landmarks,
112
+ connections=mp_face_mesh.FACEMESH_TESSELATION, # Shows mesh
113
+ # connections=mp_face_mesh.FACEMESH_CONTOURS, # Shows contours
114
+ landmark_drawing_spec=drawing_spec,
115
+ connection_drawing_spec=drawing_spec)
116
+
117
+ annotated_image_pil = Image.fromarray(image_np_to_draw)
118
+ return landmarks, None, annotated_image_pil
119
  else:
120
+ return None, "Could not detect facial landmarks.", annotated_image_pil
121
 
 
 
122
 
123
+ def _distance_2d_normalized(p1, p2): # Operates on normalized 0-1 coordinates
124
+ return math.sqrt((p1.x - p2.x)**2 + (p1.y - p2.y)**2)
 
 
 
125
 
126
+ def estimate_face_shape_from_landmarks_v2(landmarks, img_width, img_height):
 
127
  if not landmarks:
128
+ return "Unknown", {}
129
+
130
+ # --- Key Anthropometric Ratios for Face Shape ---
131
+ # Based on MediaPipe landmark indices (https://github.com/google/mediapipe/blob/master/mediapipe/python/solutions/face_mesh_connections.py)
132
+ # These are illustrative; precise points can vary based on definitions.
133
+
134
+ # Face Height: Approx. Trichion (hairline top - 10) to Gnathion (chin bottom - 152)
135
+ # Since landmark 10 can be on hair, let's use a point slightly lower or average of forehead top points
136
+ # Top of forehead (10), Midpoint between brows (e.g., 168 or 9)
137
+ # Chin point (152)
138
+ p_forehead_top_center = landmarks.landmark[10]
139
+ p_chin_bottom = landmarks.landmark[152]
140
+ face_height = abs(p_forehead_top_center.y - p_chin_bottom.y) # Using normalized Y diff
141
+
142
+ # Bizygomatic Width (Cheekbone to Cheekbone - widest part of face):
143
+ # Left Zygion (approx. 234 or 130/133) to Right Zygion (approx. 454 or 359/362)
144
+ # Let's use standard contour points for face oval: e.g., Left: 234, Right: 454
 
 
 
 
 
 
145
  p_cheek_left = landmarks.landmark[234]
146
  p_cheek_right = landmarks.landmark[454]
147
+ face_width_cheeks = abs(p_cheek_left.x - p_cheek_right.x) # Normalized X diff
148
+
149
+ # Forehead Width (between temporal crests):
150
+ # E.g., Left: 70, Right: 300 or more specific points like 54 (L) and 284 (R)
151
+ p_forehead_L = landmarks.landmark[70] # Or 103, 67
152
+ p_forehead_R = landmarks.landmark[300] # Or 332, 297
153
+ forehead_width = abs(p_forehead_L.x - p_forehead_R.x)
154
+
155
+ # Bigonial Width (Jaw Angle to Jaw Angle):
156
+ # E.g., Left Gonion (approx. 172 or 137) to Right Gonion (approx. 397 or 366)
157
+ p_jaw_angle_L = landmarks.landmark[172] # Or 147
158
+ p_jaw_angle_R = landmarks.landmark[397] # Or 376
159
+ jaw_width_gonial = abs(p_jaw_angle_L.x - p_jaw_angle_R.x)
160
+
161
+ # Chin Width (just above Gnathion, width of the chin prominence)
162
+ # E.g. landmarks 176 and 400, or slightly higher like 135 and 364
163
+ p_chin_width_L = landmarks.landmark[143] # Points on the chin body
164
+ p_chin_width_R = landmarks.landmark[372]
165
+ chin_width = abs(p_chin_width_L.x - p_chin_width_R.x)
166
+
167
+
168
+ measurements = {
169
+ "face_height_norm": face_height,
170
+ "face_width_cheeks_norm": face_width_cheeks,
171
+ "forehead_width_norm": forehead_width,
172
+ "jaw_width_gonial_norm": jaw_width_gonial,
173
+ "chin_width_norm": chin_width
174
+ }
175
+ print("Normalized Measurements:", measurements)
176
+
177
+ # --- Classification Logic (Needs significant refinement) ---
178
+ # Ratios are key to normalize for face size in image
179
+ if face_width_cheeks == 0: return "Unknown (div zero)", measurements
180
+
181
+ # Facial Index: Height / Width
182
+ facial_index = face_height / face_width_cheeks if face_width_cheeks > 0 else 0
183
+
184
+ # Relative Widths (compared to cheekbone width as it's often the widest)
185
+ forehead_to_cheek_ratio = forehead_width / face_width_cheeks
186
+ jaw_to_cheek_ratio = jaw_width_gonial / face_width_cheeks
187
+ jaw_to_forehead_ratio = jaw_width_gonial / forehead_width if forehead_width > 0 else 0
188
+
189
+ shape = "Unknown"
190
+
191
+ # These rules are more like guidelines and need extensive testing and tuning.
192
+ # Consider a decision tree or a more structured approach.
193
+ if facial_index > 1.05: # Longer than wide
194
+ if forehead_to_cheek_ratio > 0.9 and jaw_to_cheek_ratio > 0.9: # All widths similar
195
+ shape = "Long/Oblong"
196
+ elif forehead_to_cheek_ratio > jaw_to_cheek_ratio and chin_width < jaw_width_gonial * 0.7:
197
+ shape = "Heart/Inverted Triangle" # Wider forehead, narrow chin
198
+ else:
199
+ shape = "Long"
200
+ elif facial_index < 0.95: # Wider than long, or close to equal
201
+ if forehead_to_cheek_ratio > 0.85 and jaw_to_cheek_ratio > 0.85 and abs(forehead_width - jaw_width_gonial) < forehead_width * 0.15:
202
+ # Similar widths, angular jaw suggests Square, rounded suggests Round
203
+ # Differentiating Square and Round needs jawline CURVE analysis, which is harder.
204
+ # Let's use jaw_width vs cheek_width: if jaw is nearly as wide as cheeks -> Square tendency
205
+ if jaw_width_gonial > face_width_cheeks * 0.85:
206
+ shape = "Square"
207
+ else:
208
+ shape = "Round"
209
+ else: # If widths are not all similar
210
+ shape = "Round" # Default for wider faces
211
+ else: # facial_index between 0.95 and 1.05 (balanced height/width)
212
+ # Oval: Forehead slightly narrower than cheeks, jawline tapers smoothly, narrower than forehead.
213
+ # Diamond: Widest at cheeks, forehead and jaw narrower.
214
+ if face_width_cheeks > forehead_width and face_width_cheeks > jaw_width_gonial and chin_width < jaw_width_gonial * 0.8:
215
+ shape = "Diamond"
216
+ elif forehead_width > jaw_width_gonial and face_width_cheeks > jaw_width_gonial and chin_width < jaw_width_gonial * 0.75:
217
+ # forehead_to_cheek_ratio slightly less than 1 (e.g. 0.8-0.98)
218
+ # jaw_to_cheek_ratio less than forehead_to_cheek
219
+ if 0.8 < forehead_to_cheek_ratio < 1.0 and jaw_to_cheek_ratio < forehead_to_cheek_ratio * 0.95:
220
+ shape = "Oval"
221
+ else: # If forehead is widest and tapers
222
+ shape = "Heart"
223
+
224
+ elif abs(forehead_width - jaw_width_gonial) < forehead_width * 0.15 and abs(face_width_cheeks - forehead_width) < forehead_width * 0.1:
225
+ shape = "Square" # All widths relatively similar
226
+ else:
227
+ shape = "Oval" # Fallback for balanced, if not Diamond or strong Square
228
+
229
+ if shape == "Unknown": # If no specific rules matched strongly
230
+ if 0.95 <= facial_index <= 1.05 and forehead_to_cheek_ratio < 1 and jaw_to_cheek_ratio < forehead_to_cheek_ratio:
231
+ shape = "Oval (Default)"
232
+ else:
233
+ shape = "Round (Default)"
234
+
235
+ return shape, measurements
236
+
237
+
238
+ def get_side_profile_assessment(side_image_pil):
239
+ if not side_image_pil:
240
+ return "Not provided", None
241
+
242
+ side_image_pil = side_image_pil.convert("RGB")
243
+ # For simplicity, assume face is prominent in side profile.
244
+ # In a real app, run detection here too.
245
+ landmarks, error_msg_lm, _ = get_landmarks_and_draw(side_image_pil) # We don't need drawn image here
246
+
247
+ if error_msg_lm or not landmarks:
248
+ return f"Could not analyze ({error_msg_lm or 'no landmarks'})", None
249
+
250
+ # --- Assess Jawline from Side Profile ---
251
+ # Points for jaw angle (simplified):
252
+ # Point near ear lobe (e.g., landmark 127, 234 can be temple for side)
253
+ # Let's try specific side profile landmarks if they differ, or use consistent ones.
254
+ # For jaw angle: 172 (Gonion area), 152 (Chin tip/Pogonion), a point up along the ramus (e.g. 177 or 34)
255
 
256
+ # This requires good landmark stability on side profiles, which can be tricky.
257
+ # A very simple proxy: horizontal prominence of chin vs. a point higher on jaw.
258
+ p_chin_tip = landmarks.landmark[152]
259
+ p_jaw_angle_approx = landmarks.landmark[172] # Approximate gonion from front view set
260
+ p_upper_jaw_point = landmarks.landmark[135] # A point higher on the mandible body
261
+
262
+ # We need to consider the orientation. Let's assume face is looking left or right.
263
+ # A very rough heuristic:
264
+ # If chin (p_chin_tip.x) is significantly "forward" (more extreme x value, depending on orientation)
265
+ # than the jaw_angle_approx.x, it might suggest a stronger jaw.
266
+ # This is highly dependent on head rotation and landmark stability.
267
+
268
+ # A more robust method would involve angles, but that requires careful landmark selection.
269
+ # For now, let's just acknowledge if landmarks were found.
270
+ # In future, one could calculate the angle formed by landmarks such as:
271
+ # - A point on the ear (e.g. 127)
272
+ # - The gonion (jaw angle, e.g. 172 from frontal set, or a side-specific one)
273
+ # - The pogonion (chin tip, 152)
274
+ # A smaller angle (more acute) might indicate a sharper jawline.
275
 
276
+ # Example using 3 points to form an angle: A=ear_pt, B=jaw_angle_pt, C=chin_pt
277
+ # vec_BA = (A.x-B.x, A.y-B.y)
278
+ # vec_BC = (C.x-B.x, C.y-B.y)
279
+ # dot_product = vec_BA[0]*vec_BC[0] + vec_BA[1]*vec_BC[1]
280
+ # mag_BA = math.sqrt(vec_BA[0]**2 + vec_BA[1]**2)
281
+ # mag_BC = math.sqrt(vec_BC[0]**2 + vec_BC[1]**2)
282
+ # angle_rad = math.acos(dot_product / (mag_BA * mag_BC))
283
+ # angle_deg = math.degrees(angle_rad)
284
+ # This is sensitive to landmark choice and head pose.
285
+
286
+ return "Analyzed (details TBD)", landmarks # Placeholder
287
+
288
+ def get_hairstyle_suggestions_v2(face_shape, side_profile_info=""):
289
+ # (Expanded suggestion dictionary - keep it outside for brevity if very long)
290
+ # This needs to be more granular based on the new shapes from estimate_face_shape_v2
291
+ base_suggestions = {
292
+ "Oval": {"hair": ["Most styles work. Consider layers, textured crops, or side parts."], "beard": ["Versatile. Classic full beard, short boxed, or stubble."]},
293
+ "Oval (Default)": {"hair": ["Versatile. Try layers or a textured crop. Side parts can be flattering."], "beard": ["Well-groomed stubble or a short boxed beard."]},
294
+ "Long/Oblong": {"hair": ["Add width: Curls, waves, shoulder-length with layers. Bangs (blunt/side-swept). Avoid height."], "beard": ["Fuller on cheeks: full beard, mutton chops. Avoid long, pointy beards."]},
295
+ "Long": {"hair": ["Add width: Curls, waves, shoulder-length with layers. Bangs (blunt/side-swept). Avoid height."], "beard": ["Fuller on cheeks: full beard, mutton chops. Avoid long, pointy beards."]},
296
+ "Heart": {"hair": ["Add jawline volume: chin-length bobs, layered shoulder cuts. Side-swept bangs/textured fringe for forehead."], "beard": ["Fuller beards to add jaw width: Garibaldi, full beard carefully shaped."]},
297
+ "Heart/Inverted Triangle": {"hair": ["Add jawline volume: chin-length bobs, layered shoulder cuts. Side-swept bangs for forehead."], "beard": ["Fuller beards to add jaw width: Garibaldi, full beard shaped."]},
298
+ "Square": {"hair": ["Softer styles: waves, curls, layers. Textured cuts, off-center parts. Avoid sharp, geometric cuts if aiming to soften."], "beard": ["Circle beard, rounded full beard. Stubble can highlight jaw if desired."]},
299
+ "Round": {"hair": ["Add height and length: pompadour, quiff, faux hawk, side part. Layers. Avoid blunt bobs at chin or very short, round cuts."], "beard": ["Add length to chin: goatee, soul patch, beard shorter on sides & longer at chin (ducktail)."]},
300
+ "Diamond": {"hair": ["Soften forehead & jaw: chin bobs, shoulder length with layers, textured fringe. Side-swept bangs."], "beard": ["Fuller at chin, possibly some width at jaw but not cheeks: Balbo, shorter full beard."]},
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
301
  "Unknown": {"hair": ["Upload a clearer image for analysis."], "beard": ["Upload a clearer image for analysis."]},
302
+ "Unknown (div zero)": {"hair": ["Measurement error. Try different image."], "beard": ["Measurement error. Try different image."]},
303
+ "Round (Default)": {"hair": ["Add height and length: pompadour, quiff, faux hawk, side part. Layers. Avoid blunt bobs at chin or very short, round cuts."], "beard": ["Add length to chin: goatee, soul patch, beard shorter on sides & longer at chin (ducktail)."]},
304
  }
305
+
306
+ sugg = base_suggestions.get(face_shape, {"hair": ["General advice: consult a professional stylist."], "beard": ["Experiment with styles that you feel confident in."]})
307
+
308
+ hair_sug = "\n".join([f"- {s}" for s in sugg["hair"]])
309
+ beard_sug = "\n".join([f"- {s}" for s in sugg["beard"]])
310
+
311
+ # Add side profile note if relevant
312
+ if "Analyzed" in side_profile_info:
313
+ side_note = "\n\n*Side profile was analyzed. Future versions might use this for more tailored advice (e.g., jawline definition).*"
314
+ else:
315
+ side_note = ""
316
+
317
+ return f"**Haircut Suggestions for {face_shape} Face:**\n{hair_sug}\n\n**Beard Style Suggestions for {face_shape} Face:**\n{beard_sug}{side_note}"
318
 
319
 
320
+ def analyze_face_and_suggest_v2(front_image_input, side_image_input_optional):
321
  if front_image_input is None:
322
+ return None, "Please upload a front-facing photo.", "", {}
323
 
324
+ img_pil = Image.fromarray(front_image_input).convert("RGB")
 
325
 
326
+ # 1. Detect Face
327
+ cropped_face_pil, error_msg_detect = detect_face_local(img_pil)
328
+ if error_msg_detect:
329
+ return None, error_msg_detect, "", {}
330
+ if cropped_face_pil is None: # Should be caught by error_msg but as a fallback
331
+ return None, "Could not detect a face.", "", {}
332
 
333
+ # 2. Get Facial Landmarks and Draw them
334
+ landmarks, error_msg_lm, face_with_landmarks_pil = get_landmarks_and_draw(cropped_face_pil)
335
  if error_msg_lm:
336
+ return face_with_landmarks_pil, f"Face detected. Error getting landmarks: {error_msg_lm}", "Cannot suggest hairstyles without landmark analysis.", {}
 
 
 
 
 
 
 
 
 
337
 
338
+ # 3. Estimate Face Shape
339
+ img_w, img_h = cropped_face_pil.size # Use cropped face dimensions
340
+ estimated_shape, measurements = estimate_face_shape_from_landmarks_v2(landmarks, img_w, img_h)
341
+
342
+ measurements_str = "\n".join([f"- {k.replace('_norm',' (norm.)')}: {v:.2f}" for k,v in measurements.items()])
343
+ analysis_text = f"Estimated Face Shape: **{estimated_shape}**\n\nNormalized Measurements:\n{measurements_str}"
344
 
345
+ # 4. Analyze Side Profile (Basic)
346
+ side_profile_status = "Not provided or not analyzed"
347
+ side_profile_data = None
348
+ if side_image_input_optional is not None:
349
+ side_pil = Image.fromarray(side_image_input_optional).convert("RGB")
350
+ side_profile_status, side_profile_data = get_side_profile_assessment(side_pil)
351
+ analysis_text += f"\n\nSide Profile: {side_profile_status}"
352
+ # Future: Modify 'estimated_shape' or suggestions based on side_profile_data
 
 
353
 
354
+ # 5. Get Suggestions
355
+ suggestions_text = get_hairstyle_suggestions_v2(estimated_shape, side_profile_status)
356
 
357
+ return face_with_landmarks_pil, analysis_text, suggestions_text
358
 
359
  # --- Gradio Interface ---
360
  with gr.Blocks(theme=gr.themes.Soft()) as demo:
361
+ gr.Markdown("# ✂️ AI Hairstyle & Beard Suggester (Enhanced Local Models) 🧔")
362
  gr.Markdown(
363
+ "Upload a clear, front-facing photo. Optionally, upload a side profile."
364
+ "\n*Disclaimer: This app uses local AI models for face detection and landmark-based shape estimation. Suggestions are general and based on heuristics.*"
 
365
  )
366
 
367
  with gr.Row():
 
370
  side_image_input = gr.Image(type="numpy", label="Side Profile Photo (Optional)", sources=["upload", "webcam"])
371
  submit_btn = gr.Button("Get Suggestions", variant="primary")
372
  with gr.Column(scale=2):
373
+ output_image_landmarks = gr.Image(label="Detected Face with Landmarks")
374
+ output_analysis_info = gr.Markdown(label="Face Analysis & Measurements")
375
  output_suggestions = gr.Markdown(label="Suggestions")
376
 
377
  submit_btn.click(
378
+ analyze_face_and_suggest_v2,
379
  inputs=[front_image_input, side_image_input],
380
+ outputs=[output_image_landmarks, output_analysis_info, output_suggestions]
381
  )
382
+ gr.Markdown("--- \n ### Note on Face Shape Estimation: \n The face shape estimation is based on ratios of distances between facial landmarks detected by MediaPipe. The categories (Oval, Round, Square, etc.) and the rules to classify them are experimental and may require further refinement for high accuracy. Landmark visualization shows the points used.")
383
 
384
 
385
  if __name__ == "__main__":
386
+ if face_detection_model and face_mesh_detector:
387
  demo.launch()
388
  else:
389
+ print("Gradio app not launched due to model loading errors. Check face detection model name and availability.")