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

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +143 -192
app.py CHANGED
@@ -2,36 +2,48 @@ 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(
@@ -44,8 +56,7 @@ try:
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 ---
@@ -53,64 +64,59 @@ 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
 
@@ -120,51 +126,33 @@ def get_landmarks_and_draw(image_pil):
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,
@@ -172,65 +160,51 @@ def estimate_face_shape_from_landmarks_v2(landmarks, img_width, img_height):
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
 
@@ -239,68 +213,37 @@ 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."]})
@@ -308,57 +251,58 @@ def get_hairstyle_suggestions_v2(face_shape, side_profile_info=""):
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.*"
@@ -379,11 +323,18 @@ with gr.Blocks(theme=gr.themes.Soft()) as demo:
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.")
 
 
 
 
 
2
  from PIL import Image, ImageDraw
3
  import numpy as np
4
  import torch
5
+ from transformers import YolosImageProcessor, YolosForObjectDetection
6
  import mediapipe as mp
7
  import math
8
+ import os # For potential future environment variable use
9
 
10
  # --- Model Initialization ---
11
+ # 1. Face Detection Model
12
+ print("Attempting to load face detection model...")
13
+ PRIMARY_DETECTION_MODEL_NAME = "hustvl/yolos-face"
14
+ FALLBACK_DETECTION_MODEL_NAME = "hustvl/yolos-tiny" # Detects 'person'
15
+ FACE_LABEL_ID = -1 # Will be set based on which model loads
16
+
17
+ face_image_processor = None
18
+ face_detection_model = None
19
 
20
  try:
21
+ print(f"Trying primary model: {PRIMARY_DETECTION_MODEL_NAME}")
22
+ face_image_processor = YolosImageProcessor.from_pretrained(PRIMARY_DETECTION_MODEL_NAME)
23
+ face_detection_model = YolosForObjectDetection.from_pretrained(PRIMARY_DETECTION_MODEL_NAME)
24
+ # For hustvl/yolos-face, the label for "face" is 0.
25
+ FACE_LABEL_ID = 0 # Corresponds to "face"
26
+ print(f"Successfully loaded primary face detection model: {PRIMARY_DETECTION_MODEL_NAME} (label 'face': {FACE_LABEL_ID})")
27
  except Exception as e:
28
+ print(f"Error loading primary model {PRIMARY_DETECTION_MODEL_NAME}: {e}")
29
+ print(f"Attempting to load fallback model: {FALLBACK_DETECTION_MODEL_NAME}")
30
+ try:
31
+ face_image_processor = YolosImageProcessor.from_pretrained(FALLBACK_DETECTION_MODEL_NAME)
32
+ face_detection_model = YolosForObjectDetection.from_pretrained(FALLBACK_DETECTION_MODEL_NAME)
33
+ # For hustvl/yolos-tiny (trained on COCO), 'person' is label 0.
34
+ FACE_LABEL_ID = 0 # We will use 'person' (label 0) as a proxy for face
35
+ print(f"Successfully loaded fallback detection model: {FALLBACK_DETECTION_MODEL_NAME} (using label 'person': {FACE_LABEL_ID})")
36
+ except Exception as e2:
37
+ print(f"Error loading fallback model {FALLBACK_DETECTION_MODEL_NAME}: {e2}")
38
+ print("!!! CRITICAL: Face detection model could not be loaded. The app might not function correctly. !!!")
39
+ # face_image_processor and face_detection_model will remain None
40
 
41
  # 2. Facial Landmark Model (MediaPipe Face Mesh)
42
  print("Initializing MediaPipe Face Mesh...")
43
+ mp_face_mesh = None
44
+ face_mesh_detector = None
45
+ mp_drawing = None
46
+ drawing_spec = None
47
  try:
48
  mp_face_mesh = mp.solutions.face_mesh
49
  face_mesh_detector = mp_face_mesh.FaceMesh(
 
56
  print("MediaPipe Face Mesh initialized successfully.")
57
  except Exception as e:
58
  print(f"Error initializing MediaPipe Face Mesh: {e}")
59
+ # Variables will remain None
 
60
 
61
 
62
  # --- Helper Functions ---
 
64
  if not face_image_processor or not face_detection_model or FACE_LABEL_ID == -1:
65
  return None, "Face detection model not loaded or configured properly."
66
 
67
+ try:
68
+ inputs = face_image_processor(images=image_pil, return_tensors="pt")
69
+ with torch.no_grad(): # Important for inference
70
+ outputs = face_detection_model(**inputs)
71
 
72
+ target_sizes = torch.tensor([image_pil.size[::-1]])
73
+ results = face_image_processor.post_process_object_detection(outputs, threshold=0.7, target_sizes=target_sizes)[0] # Threshold
74
 
75
+ best_box = None
76
+ max_score = 0
77
 
78
+ for score, label, box in zip(results["scores"], results["labels"], results["boxes"]):
79
+ if label.item() == FACE_LABEL_ID: # Use .item() to get Python int from tensor
80
+ if score.item() > max_score:
81
+ max_score = score.item()
82
+ best_box = box.tolist()
83
 
84
+ if best_box:
85
+ padding_w = (best_box[2] - best_box[0]) * 0.15 # 15% padding width
86
+ padding_h = (best_box[3] - best_box[1]) * 0.15 # 15% padding height
 
87
 
88
+ xmin = max(0, best_box[0] - padding_w)
89
+ ymin = max(0, best_box[1] - padding_h)
90
+ xmax = min(image_pil.width, best_box[2] + padding_w)
91
+ ymax = min(image_pil.height, best_box[3] + padding_h)
92
+
93
+ cropped_image = image_pil.crop((xmin, ymin, xmax, ymax))
94
+ return cropped_image, None
95
+ else:
96
+ return None, "No face/person detected with sufficient confidence."
97
+ except Exception as e:
98
+ print(f"Error during local face detection: {e}")
99
+ return None, f"Error during face detection: {str(e)}"
100
 
 
 
 
 
101
 
102
  def get_landmarks_and_draw(image_pil):
103
+ if not face_mesh_detector or not mp_drawing or not drawing_spec:
104
+ return None, "MediaPipe Face Mesh not initialized for landmarks.", image_pil
105
 
106
+ image_rgb_mp = np.array(image_pil.convert('RGB')) # MediaPipe prefers RGB
107
  results = face_mesh_detector.process(image_rgb_mp)
108
 
109
+ annotated_image_pil = image_pil.copy()
110
 
111
  if results.multi_face_landmarks:
112
+ landmarks = results.multi_face_landmarks[0]
 
 
 
113
  image_np_to_draw = np.array(annotated_image_pil)
114
+
115
+ # Draw landmarks using MediaPipe's utility
 
 
 
 
 
116
  mp_drawing.draw_landmarks(
117
  image=image_np_to_draw,
118
  landmark_list=landmarks,
119
  connections=mp_face_mesh.FACEMESH_TESSELATION, # Shows mesh
 
120
  landmark_drawing_spec=drawing_spec,
121
  connection_drawing_spec=drawing_spec)
122
 
 
126
  return None, "Could not detect facial landmarks.", annotated_image_pil
127
 
128
 
129
+ def _distance_2d_normalized(p1, p2):
130
  return math.sqrt((p1.x - p2.x)**2 + (p1.y - p2.y)**2)
131
 
132
  def estimate_face_shape_from_landmarks_v2(landmarks, img_width, img_height):
133
  if not landmarks:
134
  return "Unknown", {}
135
 
 
 
 
 
 
 
 
 
136
  p_forehead_top_center = landmarks.landmark[10]
137
  p_chin_bottom = landmarks.landmark[152]
138
+ face_height = abs(p_forehead_top_center.y - p_chin_bottom.y)
139
 
 
 
 
140
  p_cheek_left = landmarks.landmark[234]
141
  p_cheek_right = landmarks.landmark[454]
142
+ face_width_cheeks = abs(p_cheek_left.x - p_cheek_right.x)
143
 
144
+ p_forehead_L = landmarks.landmark[70]
145
+ p_forehead_R = landmarks.landmark[300]
 
 
146
  forehead_width = abs(p_forehead_L.x - p_forehead_R.x)
147
 
148
+ p_jaw_angle_L = landmarks.landmark[172]
149
+ p_jaw_angle_R = landmarks.landmark[397]
 
 
150
  jaw_width_gonial = abs(p_jaw_angle_L.x - p_jaw_angle_R.x)
151
 
152
+ p_chin_width_L = landmarks.landmark[143]
 
 
153
  p_chin_width_R = landmarks.landmark[372]
154
  chin_width = abs(p_chin_width_L.x - p_chin_width_R.x)
155
 
 
156
  measurements = {
157
  "face_height_norm": face_height,
158
  "face_width_cheeks_norm": face_width_cheeks,
 
160
  "jaw_width_gonial_norm": jaw_width_gonial,
161
  "chin_width_norm": chin_width
162
  }
163
+ # print("Normalized Measurements:", {k: round(v,3) for k,v in measurements.items()})
164
 
 
 
165
  if face_width_cheeks == 0: return "Unknown (div zero)", measurements
166
 
 
167
  facial_index = face_height / face_width_cheeks if face_width_cheeks > 0 else 0
 
 
168
  forehead_to_cheek_ratio = forehead_width / face_width_cheeks
169
  jaw_to_cheek_ratio = jaw_width_gonial / face_width_cheeks
170
+
 
171
  shape = "Unknown"
172
 
 
 
173
  if facial_index > 1.05: # Longer than wide
174
+ if forehead_to_cheek_ratio > 0.85 and jaw_to_cheek_ratio > 0.85 and abs(forehead_width - jaw_width_gonial) < forehead_width * 0.20 :
175
+ shape = "Long/Oblong" # All widths relatively similar but face is long
176
+ elif forehead_width > jaw_width_gonial and chin_width < jaw_width_gonial * 0.85:
177
+ shape = "Heart/Inverted Triangle"
178
  else:
179
  shape = "Long"
180
+ elif facial_index < 0.95: # Wider than long, or close to equal width/height and not distinctly Diamond/Heart
181
+ if forehead_to_cheek_ratio > 0.85 and jaw_to_cheek_ratio > 0.85 and abs(forehead_width - jaw_width_gonial) < forehead_width * 0.20:
182
+ if jaw_width_gonial > face_width_cheeks * 0.88: # Strong jaw compared to cheeks
 
 
 
183
  shape = "Square"
184
  else:
185
  shape = "Round"
186
+ else: # If widths are not all similar, default to Round for wider faces
187
+ shape = "Round"
188
  else: # facial_index between 0.95 and 1.05 (balanced height/width)
189
+ if face_width_cheeks > forehead_width and face_width_cheeks > jaw_width_gonial and chin_width < jaw_width_gonial * 0.85:
 
 
190
  shape = "Diamond"
191
+ elif forehead_width > jaw_width_gonial and face_width_cheeks > jaw_width_gonial and chin_width < jaw_width_gonial * 0.8:
192
+ if 0.80 < forehead_to_cheek_ratio < 1.0 and jaw_to_cheek_ratio < forehead_to_cheek_ratio * 0.95:
 
 
193
  shape = "Oval"
194
+ else:
195
  shape = "Heart"
196
+ elif abs(forehead_width - jaw_width_gonial) < forehead_width * 0.15 and abs(face_width_cheeks - forehead_width) < forehead_width * 0.15 :
197
+ shape = "Square"
 
198
  else:
199
+ shape = "Oval" # General fallback for balanced faces not matching other criteria
200
 
201
  if shape == "Unknown": # If no specific rules matched strongly
202
+ if 0.95 <= facial_index <= 1.05 and forehead_to_cheek_ratio < 1.0 and jaw_to_cheek_ratio < forehead_to_cheek_ratio:
203
  shape = "Oval (Default)"
204
+ elif facial_index < 0.95:
205
+ shape = "Round (Default)"
206
  else:
207
+ shape = "Long (Default)"
208
 
209
  return shape, measurements
210
 
 
213
  if not side_image_pil:
214
  return "Not provided", None
215
 
216
+ # Convert Gradio Image (numpy array) to PIL Image if it's not already
217
+ if isinstance(side_image_pil, np.ndarray):
218
+ side_image_pil = Image.fromarray(side_image_pil)
219
+
220
  side_image_pil = side_image_pil.convert("RGB")
221
+ landmarks, error_msg_lm, _ = get_landmarks_and_draw(side_image_pil)
 
 
222
 
223
  if error_msg_lm or not landmarks:
224
  return f"Could not analyze ({error_msg_lm or 'no landmarks'})", None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
225
 
226
+ # Basic assessment placeholder
227
+ # E.g. Chin prominence (landmark 152's x vs jaw angle 172's x)
228
+ # This is highly dependent on consistent side view and requires careful calibration
229
+ # For now, just acknowledge landmarks were found
230
+ return "Analyzed (basic landmark detection)", landmarks
 
 
 
 
 
 
231
 
232
  def get_hairstyle_suggestions_v2(face_shape, side_profile_info=""):
 
 
233
  base_suggestions = {
234
  "Oval": {"hair": ["Most styles work. Consider layers, textured crops, or side parts."], "beard": ["Versatile. Classic full beard, short boxed, or stubble."]},
235
  "Oval (Default)": {"hair": ["Versatile. Try layers or a textured crop. Side parts can be flattering."], "beard": ["Well-groomed stubble or a short boxed beard."]},
236
  "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."]},
237
  "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."]},
238
+ "Long (Default)": {"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."]},
239
  "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."]},
240
  "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."]},
241
  "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."]},
242
  "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)."]},
243
+ "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)."]},
244
  "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."]},
245
  "Unknown": {"hair": ["Upload a clearer image for analysis."], "beard": ["Upload a clearer image for analysis."]},
246
  "Unknown (div zero)": {"hair": ["Measurement error. Try different image."], "beard": ["Measurement error. Try different image."]},
 
247
  }
248
 
249
  sugg = base_suggestions.get(face_shape, {"hair": ["General advice: consult a professional stylist."], "beard": ["Experiment with styles that you feel confident in."]})
 
251
  hair_sug = "\n".join([f"- {s}" for s in sugg["hair"]])
252
  beard_sug = "\n".join([f"- {s}" for s in sugg["beard"]])
253
 
254
+ side_note = ""
255
  if "Analyzed" in side_profile_info:
256
+ side_note = "\n\n*Side profile analyzed. Future versions could use this for more tailored advice (e.g., jawline definition).*"
257
+ elif "Not provided" not in side_profile_info and side_profile_info: # If there was an attempt but it failed
258
+ side_note = f"\n\n*Side profile: {side_profile_info}*"
259
+
260
 
261
  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}"
262
 
263
 
264
  def analyze_face_and_suggest_v2(front_image_input, side_image_input_optional):
265
  if front_image_input is None:
266
+ return None, "Please upload a front-facing photo.", ""
267
+
268
+ # Ensure models are loaded
269
+ if not face_detection_model or not face_mesh_detector:
270
+ error_msg = []
271
+ if not face_detection_model: error_msg.append("Face detector not loaded.")
272
+ if not face_mesh_detector: error_msg.append("Landmark detector not loaded.")
273
+ return None, " ".join(error_msg) + " Please check Space logs.", ""
274
 
275
  img_pil = Image.fromarray(front_image_input).convert("RGB")
276
 
 
277
  cropped_face_pil, error_msg_detect = detect_face_local(img_pil)
278
  if error_msg_detect:
279
+ return None, error_msg_detect, "" # No measurements if face detection fails
280
+ if cropped_face_pil is None:
281
+ return None, "Could not detect a face.", ""
282
 
 
283
  landmarks, error_msg_lm, face_with_landmarks_pil = get_landmarks_and_draw(cropped_face_pil)
284
  if error_msg_lm:
285
+ return face_with_landmarks_pil, f"Face detected. Error getting landmarks: {error_msg_lm}", "Cannot suggest hairstyles without landmark analysis."
286
 
287
+ img_w, img_h = cropped_face_pil.size
 
288
  estimated_shape, measurements = estimate_face_shape_from_landmarks_v2(landmarks, img_w, img_h)
289
 
290
+ measurements_str = "\n".join([f"- {k.replace('_norm',' (norm. ratio)'):<25}: {v:.3f}" for k,v in measurements.items()])
291
  analysis_text = f"Estimated Face Shape: **{estimated_shape}**\n\nNormalized Measurements:\n{measurements_str}"
292
 
293
+ side_profile_status = "Not provided"
 
 
294
  if side_image_input_optional is not None:
295
+ # Pass the numpy array directly
296
+ side_profile_status, _ = get_side_profile_assessment(side_image_input_optional)
297
  analysis_text += f"\n\nSide Profile: {side_profile_status}"
 
298
 
 
299
  suggestions_text = get_hairstyle_suggestions_v2(estimated_shape, side_profile_status)
300
 
301
  return face_with_landmarks_pil, analysis_text, suggestions_text
302
 
303
  # --- Gradio Interface ---
304
  with gr.Blocks(theme=gr.themes.Soft()) as demo:
305
+ gr.Markdown("# ✂️ AI Hairstyle & Beard Suggester 🧔")
306
  gr.Markdown(
307
  "Upload a clear, front-facing photo. Optionally, upload a side profile."
308
  "\n*Disclaimer: This app uses local AI models for face detection and landmark-based shape estimation. Suggestions are general and based on heuristics.*"
 
323
  inputs=[front_image_input, side_image_input],
324
  outputs=[output_image_landmarks, output_analysis_info, output_suggestions]
325
  )
326
+ gr.Markdown("--- \n ### Notes: \n - **Face Shape Estimation:** Based on ratios of distances between facial landmarks (MediaPipe). The categories (Oval, Round, etc.) and classification rules are experimental. \n - **Landmark Visualization:** Green mesh shows detected facial landmarks. \n - **Model Loading:** Tries `hustvl/yolos-face` first, then `hustvl/yolos-tiny` (person detection) as fallback. Check Space logs for details.")
327
 
328
 
329
  if __name__ == "__main__":
330
+ # Only launch if at least the fallback detection model and mediapipe loaded
331
+ if (face_detection_model and face_image_processor and FACE_LABEL_ID != -1) and \
332
+ (face_mesh_detector and mp_drawing and drawing_spec):
333
+ print("Launching Gradio App...")
334
  demo.launch()
335
  else:
336
+ print("Gradio app not launched due to critical model loading errors. Please check the logs.")
337
+ if not (face_detection_model and face_image_processor and FACE_LABEL_ID != -1):
338
+ print("-> Face detection model failed to load.")
339
+ if not (face_mesh_detector and mp_drawing and drawing_spec):
340
+ print("-> MediaPipe landmark model failed to initialize.")