Spaces:
Running
Running
Update app.py
Browse files
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
|
5 |
-
from transformers import YolosImageProcessor, YolosForObjectDetection
|
6 |
import mediapipe as mp
|
7 |
-
import math
|
8 |
|
9 |
-
# --- Model Initialization
|
10 |
-
# 1. Face Detection Model (
|
11 |
print("Loading face detection model...")
|
12 |
-
|
|
|
|
|
|
|
|
|
13 |
try:
|
14 |
face_image_processor = YolosImageProcessor.from_pretrained(DETECTION_MODEL_NAME)
|
15 |
face_detection_model = YolosForObjectDetection.from_pretrained(DETECTION_MODEL_NAME)
|
16 |
-
|
|
|
|
|
|
|
17 |
except Exception as e:
|
18 |
-
print(f"Error loading
|
|
|
|
|
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,
|
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 |
-
|
|
|
48 |
|
49 |
-
|
50 |
-
|
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 |
-
|
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()
|
73 |
|
74 |
if best_box:
|
75 |
-
|
76 |
-
|
77 |
-
|
78 |
-
return None, "No face/person detected with sufficient confidence."
|
79 |
|
80 |
-
|
81 |
-
|
82 |
-
|
|
|
83 |
|
84 |
-
|
85 |
-
|
86 |
-
|
87 |
-
|
88 |
-
|
|
|
|
|
|
|
89 |
|
|
|
90 |
results = face_mesh_detector.process(image_rgb_mp)
|
91 |
|
|
|
|
|
92 |
if results.multi_face_landmarks:
|
93 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
101 |
-
|
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
|
112 |
-
#
|
113 |
-
#
|
114 |
-
|
115 |
-
#
|
116 |
-
#
|
117 |
-
#
|
118 |
-
|
119 |
-
|
120 |
-
|
121 |
-
|
122 |
-
|
123 |
-
#
|
124 |
-
#
|
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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
-
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 (
|
|
|
203 |
}
|
204 |
-
|
205 |
-
|
206 |
-
|
207 |
-
|
208 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
209 |
|
210 |
|
211 |
-
def
|
212 |
if front_image_input is None:
|
213 |
-
return None, "Please upload a front-facing photo.", ""
|
214 |
|
215 |
-
|
216 |
-
img_pil = Image.fromarray(front_image_input).convert("RGB") # Ensure RGB
|
217 |
|
218 |
-
# 1. Detect Face
|
219 |
-
cropped_face_pil,
|
220 |
-
if
|
221 |
-
return None,
|
222 |
-
if cropped_face_pil is None:
|
223 |
-
return None, "Could not detect a face.", ""
|
224 |
|
225 |
-
# 2. Get Facial Landmarks
|
226 |
-
landmarks, error_msg_lm =
|
227 |
if error_msg_lm:
|
228 |
-
|
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 |
-
#
|
241 |
-
|
242 |
-
|
243 |
-
|
244 |
-
|
245 |
-
|
246 |
-
|
247 |
-
|
248 |
-
# Potentially run detection + landmarks on side_image_optional here too
|
249 |
-
# And combine information for a more robust `estimated_shape`
|
250 |
|
251 |
-
#
|
252 |
-
suggestions_text =
|
253 |
|
254 |
-
return
|
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 |
-
"
|
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 |
-
|
272 |
-
|
273 |
output_suggestions = gr.Markdown(label="Suggestions")
|
274 |
|
275 |
submit_btn.click(
|
276 |
-
|
277 |
inputs=[front_image_input, side_image_input],
|
278 |
-
outputs=[
|
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
|
281 |
|
282 |
|
283 |
if __name__ == "__main__":
|
284 |
-
if face_detection_model and face_mesh_detector:
|
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.")
|