Stylique commited on
Commit
ceaae7c
Β·
verified Β·
1 Parent(s): 5d19424

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +447 -123
app.py CHANGED
@@ -1,5 +1,4 @@
1
  from typing import Tuple, Optional, List, Dict
2
-
3
  import cv2
4
  import gradio as gr
5
  import numpy as np
@@ -7,17 +6,13 @@ from PIL import Image
7
  import torch
8
  from functools import lru_cache
9
  from transformers import AutoImageProcessor, AutoModelForSemanticSegmentation
10
-
11
  import mediapipe as mp # MediaPipe is mandatory
12
- HAS_MEDIAPIPE = True
13
 
 
14
 
15
  def _ensure_rgb_uint8(image: np.ndarray) -> np.ndarray:
16
- """Convert an input image array to RGB uint8 format.
17
-
18
- Gradio provides images as numpy arrays in RGB order with dtype uint8 by default,
19
- but we defensively normalize here in case inputs vary.
20
- """
21
  if image is None:
22
  raise ValueError("No image provided")
23
 
@@ -33,6 +28,42 @@ def _ensure_rgb_uint8(image: np.ndarray) -> np.ndarray:
33
  return image
34
 
35
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  def _central_crop_bbox(width: int, height: int, frac: float = 0.6) -> Tuple[int, int, int, int]:
37
  """Return a central crop bounding box (x1, y1, x2, y2) covering `frac` of width/height."""
38
  frac = float(np.clip(frac, 0.2, 1.0))
@@ -46,43 +77,121 @@ def _central_crop_bbox(width: int, height: int, frac: float = 0.6) -> Tuple[int,
46
 
47
 
48
  def _detect_face_bbox_mediapipe(image_rgb: np.ndarray) -> Optional[Tuple[int, int, int, int]]:
49
- """Detect a face bounding box using MediaPipe Face Detection and return (x1, y1, x2, y2).
50
-
51
- Returns None if detection fails or mediapipe is unavailable.
52
- """
53
- if not HAS_MEDIAPIPE:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  return None
55
- height, width = image_rgb.shape[:2]
 
 
 
56
  try:
57
- with mp.solutions.face_detection.FaceDetection(model_selection=1, min_detection_confidence=0.5) as detector:
58
- results = detector.process(image_rgb)
59
- detections = results.detections or []
60
- if not detections:
61
- return None
62
- # Pick the largest bbox
63
- def bbox_area(det):
64
- bbox = det.location_data.relative_bounding_box
65
- return max(0.0, bbox.width) * max(0.0, bbox.height)
66
-
67
- best = max(detections, key=bbox_area)
68
- rb = best.location_data.relative_bounding_box
69
- x1 = int(np.clip(rb.xmin * width, 0, width - 1))
70
- y1 = int(np.clip(rb.ymin * height, 0, height - 1))
71
- x2 = int(np.clip((rb.xmin + rb.width) * width, 0, width))
72
- y2 = int(np.clip((rb.ymin + rb.height) * height, 0, height))
73
-
74
- # Expand a bit to include cheeks/forehead
75
- pad_x = int(0.08 * width)
76
- pad_y = int(0.12 * height)
77
- x1 = int(np.clip(x1 - pad_x, 0, width - 1))
78
- y1 = int(np.clip(y1 - pad_y, 0, height - 1))
79
- x2 = int(np.clip(x2 + pad_x, 0, width))
80
- y2 = int(np.clip(y2 + pad_y, 0, height))
81
-
82
- if x2 - x1 < 10 or y2 - y1 < 10:
83
- return None
84
- return x1, y1, x2, y2
85
- except Exception:
 
 
 
 
 
 
 
 
 
 
 
86
  return None
87
 
88
 
@@ -110,11 +219,20 @@ def _segment_face_labels(image_rgb: np.ndarray) -> Tuple[np.ndarray, Dict[int, s
110
  """Run face-parsing segmentation on an RGB crop. Returns (labels HxW int, id2label)."""
111
  processor, model, id2label, _ = _load_face_parsing_model()
112
  pil_img = Image.fromarray(image_rgb)
 
 
 
 
 
 
 
 
113
  inputs = processor(images=pil_img, return_tensors="pt")
 
114
  with torch.no_grad():
115
  outputs = model(**inputs)
116
- logits = outputs.logits # (1, num_labels, h', w')
117
-
118
  # Upsample to original image size
119
  upsampled = torch.nn.functional.interpolate(
120
  logits,
@@ -132,36 +250,49 @@ def _skin_indices_from_id2label(id2label: Dict[int, str]) -> List[int]:
132
  name_l = name.lower()
133
  if "skin" in name_l:
134
  skin_indices.append(int(idx))
135
- # Fallback: some models may label general face region as 'face'
 
 
 
136
  if not skin_indices:
137
- for idx, name in id2label.items():
138
- if "face" in name.lower():
139
- skin_indices.append(int(idx))
 
 
 
140
  return skin_indices
141
 
142
 
143
  def _compute_skin_color_hex(image_rgb: np.ndarray, mask: np.ndarray) -> Tuple[str, np.ndarray]:
144
- """Compute a robust representative skin color as a hex string and return also the RGB color.
145
-
146
- Uses median across masked pixels to reduce influence of highlights/shadows.
147
- """
148
  if mask is None or mask.size == 0:
149
  raise ValueError("Invalid mask for skin color computation")
150
-
151
  # boolean mask for indexing
152
  mask_bool = mask.astype(bool)
153
  if not np.any(mask_bool):
154
  raise ValueError("No skin pixels detected")
155
-
156
  skin_pixels = image_rgb[mask_bool]
157
-
158
- # Robust median to mitigate outliers
159
  median_color = np.median(skin_pixels, axis=0)
160
  median_color = np.clip(median_color, 0, 255).astype(np.uint8)
161
-
162
- r, g, b = int(median_color[0]), int(median_color[1]), int(median_color[2])
 
 
 
 
 
 
 
 
 
 
163
  hex_code = f"#{r:02X}{g:02X}{b:02X}"
164
- return hex_code, median_color
165
 
166
 
167
  def _solid_color_image(color_rgb: np.ndarray, size: Tuple[int, int] = (160, 160)) -> np.ndarray:
@@ -171,42 +302,140 @@ def _solid_color_image(color_rgb: np.ndarray, size: Tuple[int, int] = (160, 160)
171
 
172
 
173
  def detect_skin_tone(image: np.ndarray) -> Tuple[str, np.ndarray, np.ndarray]:
174
- """Main pipeline: returns (hex_code, color_swatch_image, debug_mask_overlay).
175
-
176
- - image: input image as numpy array (H, W, 3) RGB uint8
177
- - center_focus: if True, prioritizes central crop region to avoid background/hands
178
- """
179
- rgb = _ensure_rgb_uint8(image)
180
- height, width = rgb.shape[:2]
181
-
182
- # Mandatory: detect face with MediaPipe
183
- face_bbox = _detect_face_bbox_mediapipe(rgb)
184
- if face_bbox is None:
185
- raise ValueError("No face detected. Please upload an image with a clear frontal face.")
186
- x1, y1, x2, y2 = face_bbox
187
- central_rgb = rgb[y1:y2, x1:x2]
188
-
189
- # Face parsing segmentation to get skin mask
190
- labels, id2label = _segment_face_labels(central_rgb)
191
- skin_indices = _skin_indices_from_id2label(id2label)
192
- if not skin_indices:
193
- raise ValueError("Face parsing model did not expose a skin class.")
194
-
195
- skin_mask = np.isin(labels, np.array(skin_indices, dtype=np.int32)).astype(np.uint8) * 255
196
-
197
- # Compute color from masked central region
198
- hex_code, color_rgb = _compute_skin_color_hex(central_rgb, skin_mask)
199
-
200
- # Prepare swatch and debug visualization
201
- swatch = _solid_color_image(color_rgb)
202
-
203
- # Place mask back into full image coordinates for visualization
204
- full_mask = np.zeros((height, width), dtype=np.uint8)
205
- full_mask[y1:y2, x1:x2] = skin_mask
206
- color_mask = cv2.cvtColor(full_mask, cv2.COLOR_GRAY2RGB)
207
- overlay = cv2.addWeighted(rgb, 0.8, color_mask, 0.2, 0)
208
-
209
- return hex_code, swatch, overlay
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
210
 
211
 
212
  def _hex_html(hex_code: str) -> str:
@@ -214,52 +443,147 @@ def _hex_html(hex_code: str) -> str:
214
  "display:flex;align-items:center;gap:12px;padding:8px 0;"
215
  )
216
  swatch_style = (
217
- f"width:20px;height:20px;border-radius:4px;background:{hex_code};"
218
- "border:1px solid #ccc;"
219
  )
220
  return (
221
  f"<div style='{style}'>"
222
  f"<div style='{swatch_style}'></div>"
223
- f"<span style='font-family:monospace;font-size:16px'>{hex_code}</span>"
224
  "</div>"
225
  )
226
 
227
 
228
- with gr.Blocks(title="Skin Tone Detector") as demo:
 
229
  gr.Markdown(
230
  """
231
- ### Skin Tone Hex Detector
232
- Upload a face image. The app estimates a representative skin tone and returns a HEX color.
 
 
 
 
 
 
 
 
 
233
  """
234
  )
235
-
236
  with gr.Row():
237
- with gr.Column():
238
  input_image = gr.Image(
239
- label="Upload face image",
240
  type="numpy",
241
  image_mode="RGB",
242
- height=360,
 
 
243
  )
244
- run_btn = gr.Button("Detect Skin Tone", variant="primary")
245
-
246
- with gr.Column():
247
- hex_output = gr.HTML(label="HEX Color")
248
- swatch_output = gr.Image(label="Color Swatch", type="numpy")
249
- debug_output = gr.Image(label="Mask Overlay", type="numpy")
250
- gr.Markdown("MediaPipe face detection and a face-parsing model are used to isolate skin pixels.")
251
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
252
  def _run(image: Optional[np.ndarray]):
253
  if image is None:
254
- return _hex_html("#000000"), np.zeros((160, 160, 3), dtype=np.uint8), None
255
- hex_code, swatch, debug = detect_skin_tone(image)
256
- return _hex_html(hex_code), swatch, debug
257
-
258
- run_btn.click(_run, inputs=[input_image], outputs=[hex_output, swatch_output, debug_output])
259
- input_image.change(_run, inputs=[input_image], outputs=[hex_output, swatch_output, debug_output])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
260
 
261
 
262
  if __name__ == "__main__":
263
- demo.launch()
264
-
265
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from typing import Tuple, Optional, List, Dict
 
2
  import cv2
3
  import gradio as gr
4
  import numpy as np
 
6
  import torch
7
  from functools import lru_cache
8
  from transformers import AutoImageProcessor, AutoModelForSemanticSegmentation
 
9
  import mediapipe as mp # MediaPipe is mandatory
10
+ import warnings
11
 
12
+ warnings.filterwarnings('ignore')
13
 
14
  def _ensure_rgb_uint8(image: np.ndarray) -> np.ndarray:
15
+ """Convert an input image array to RGB uint8 format."""
 
 
 
 
16
  if image is None:
17
  raise ValueError("No image provided")
18
 
 
28
  return image
29
 
30
 
31
+ def _preprocess_image(image: np.ndarray) -> np.ndarray:
32
+ """Preprocess image to improve face detection."""
33
+ rgb = _ensure_rgb_uint8(image)
34
+
35
+ # Resize if image is too large or too small
36
+ h, w = rgb.shape[:2]
37
+
38
+ # If too large, resize down
39
+ max_dim = 1024
40
+ if max(h, w) > max_dim:
41
+ scale = max_dim / max(h, w)
42
+ new_w = int(w * scale)
43
+ new_h = int(h * scale)
44
+ rgb = cv2.resize(rgb, (new_w, new_h), interpolation=cv2.INTER_AREA)
45
+
46
+ # If too small, resize up
47
+ min_dim = 200
48
+ if min(h, w) < min_dim:
49
+ scale = min_dim / min(h, w)
50
+ new_w = int(w * scale)
51
+ new_h = int(h * scale)
52
+ rgb = cv2.resize(rgb, (new_w, new_h), interpolation=cv2.INTER_CUBIC)
53
+
54
+ # Apply contrast enhancement if image is dark
55
+ gray = cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY)
56
+ if np.mean(gray) < 50: # Too dark
57
+ lab = cv2.cvtColor(rgb, cv2.COLOR_RGB2LAB)
58
+ l, a, b = cv2.split(lab)
59
+ clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
60
+ l = clahe.apply(l)
61
+ lab = cv2.merge((l, a, b))
62
+ rgb = cv2.cvtColor(lab, cv2.COLOR_LAB2RGB)
63
+
64
+ return rgb
65
+
66
+
67
  def _central_crop_bbox(width: int, height: int, frac: float = 0.6) -> Tuple[int, int, int, int]:
68
  """Return a central crop bounding box (x1, y1, x2, y2) covering `frac` of width/height."""
69
  frac = float(np.clip(frac, 0.2, 1.0))
 
77
 
78
 
79
  def _detect_face_bbox_mediapipe(image_rgb: np.ndarray) -> Optional[Tuple[int, int, int, int]]:
80
+ """Detect a face bounding box using MediaPipe Face Detection and return (x1, y1, x2, y2)."""
81
+ try:
82
+ height, width = image_rgb.shape[:2]
83
+
84
+ # Initialize MediaPipe Face Detection
85
+ mp_face_detection = mp.solutions.face_detection
86
+ face_detection = mp_face_detection.FaceDetection(
87
+ model_selection=1, # 1 for front-facing, 2 for full-range
88
+ min_detection_confidence=0.3 # Lower confidence for better detection
89
+ )
90
+
91
+ # Convert to BGR for MediaPipe (MediaPipe expects BGR)
92
+ image_bgr = cv2.cvtColor(image_rgb, cv2.COLOR_RGB2BGR)
93
+ results = face_detection.process(image_bgr)
94
+ face_detection.close()
95
+
96
+ if not results.detections:
97
+ return None
98
+
99
+ # Get all detections
100
+ detections = []
101
+ for detection in results.detections:
102
+ bbox = detection.location_data.relative_bounding_box
103
+ confidence = detection.score[0]
104
+
105
+ # Convert normalized coordinates to pixel coordinates
106
+ x = int(bbox.xmin * width)
107
+ y = int(bbox.ymin * height)
108
+ w = int(bbox.width * width)
109
+ h = int(bbox.height * height)
110
+
111
+ # Ensure coordinates are within image bounds
112
+ x = max(0, x)
113
+ y = max(0, y)
114
+ w = min(width - x, w)
115
+ h = min(height - y, h)
116
+
117
+ if w > 0 and h > 0:
118
+ detections.append({
119
+ 'bbox': (x, y, w, h),
120
+ 'confidence': confidence
121
+ })
122
+
123
+ if not detections:
124
+ return None
125
+
126
+ # Sort by confidence and pick the best
127
+ detections.sort(key=lambda d: d['confidence'], reverse=True)
128
+ best = detections[0]
129
+ x, y, w, h = best['bbox']
130
+
131
+ # Expand the bounding box to include more context
132
+ expand_x = int(w * 0.15)
133
+ expand_y = int(h * 0.20)
134
+
135
+ x1 = max(0, x - expand_x)
136
+ y1 = max(0, y - expand_y)
137
+ x2 = min(width, x + w + expand_x)
138
+ y2 = min(height, y + h + expand_y)
139
+
140
+ # Ensure minimum size
141
+ if (x2 - x1) < 50 or (y2 - y1) < 50:
142
+ # If too small, use central crop instead
143
+ return None
144
+
145
+ return x1, y1, x2, y2
146
+
147
+ except Exception as e:
148
+ print(f"MediaPipe error: {e}")
149
  return None
150
+
151
+
152
+ def _detect_face_bbox_opencv(image_rgb: np.ndarray) -> Optional[Tuple[int, int, int, int]]:
153
+ """Fallback face detection using OpenCV Haar cascades."""
154
  try:
155
+ gray = cv2.cvtColor(image_rgb, cv2.COLOR_RGB2GRAY)
156
+
157
+ # Load pre-trained Haar cascade
158
+ cascade_path = cv2.data.haarcascades + 'haarcascade_frontalface_default.xml'
159
+ face_cascade = cv2.CascadeClassifier(cascade_path)
160
+
161
+ if face_cascade.empty():
162
+ print("Haar cascade not loaded properly")
163
+ return None
164
+
165
+ # Detect faces
166
+ faces = face_cascade.detectMultiScale(
167
+ gray,
168
+ scaleFactor=1.1,
169
+ minNeighbors=5,
170
+ minSize=(30, 30),
171
+ flags=cv2.CASCADE_SCALE_IMAGE
172
+ )
173
+
174
+ if len(faces) == 0:
175
+ return None
176
+
177
+ # Get the largest face
178
+ faces = sorted(faces, key=lambda f: f[2] * f[3], reverse=True)
179
+ x, y, w, h = faces[0]
180
+
181
+ # Expand bounding box
182
+ expand_x = int(w * 0.15)
183
+ expand_y = int(h * 0.20)
184
+
185
+ height, width = image_rgb.shape[:2]
186
+ x1 = max(0, x - expand_x)
187
+ y1 = max(0, y - expand_y)
188
+ x2 = min(width, x + w + expand_x)
189
+ y2 = min(height, y + h + expand_y)
190
+
191
+ return x1, y1, x2, y2
192
+
193
+ except Exception as e:
194
+ print(f"OpenCV face detection error: {e}")
195
  return None
196
 
197
 
 
219
  """Run face-parsing segmentation on an RGB crop. Returns (labels HxW int, id2label)."""
220
  processor, model, id2label, _ = _load_face_parsing_model()
221
  pil_img = Image.fromarray(image_rgb)
222
+
223
+ # Resize if too large for the model
224
+ max_size = 512
225
+ if max(pil_img.size) > max_size:
226
+ scale = max_size / max(pil_img.size)
227
+ new_size = (int(pil_img.size[0] * scale), int(pil_img.size[1] * scale))
228
+ pil_img = pil_img.resize(new_size, Image.Resampling.LANCZOS)
229
+
230
  inputs = processor(images=pil_img, return_tensors="pt")
231
+
232
  with torch.no_grad():
233
  outputs = model(**inputs)
234
+ logits = outputs.logits
235
+
236
  # Upsample to original image size
237
  upsampled = torch.nn.functional.interpolate(
238
  logits,
 
250
  name_l = name.lower()
251
  if "skin" in name_l:
252
  skin_indices.append(int(idx))
253
+ elif "face" in name_l and "skin" not in name_l and "hair" not in name_l:
254
+ skin_indices.append(int(idx))
255
+
256
+ # Default fallback indices (common in face-parsing models)
257
  if not skin_indices:
258
+ # Try common skin class indices
259
+ common_skin_indices = [1, 13, 14, 15] # These vary by model
260
+ for idx in common_skin_indices:
261
+ if idx in id2label:
262
+ skin_indices.append(idx)
263
+
264
  return skin_indices
265
 
266
 
267
  def _compute_skin_color_hex(image_rgb: np.ndarray, mask: np.ndarray) -> Tuple[str, np.ndarray]:
268
+ """Compute a robust representative skin color as a hex string and return also the RGB color."""
 
 
 
269
  if mask is None or mask.size == 0:
270
  raise ValueError("Invalid mask for skin color computation")
271
+
272
  # boolean mask for indexing
273
  mask_bool = mask.astype(bool)
274
  if not np.any(mask_bool):
275
  raise ValueError("No skin pixels detected")
276
+
277
  skin_pixels = image_rgb[mask_bool]
278
+
279
+ # Use median for robustness
280
  median_color = np.median(skin_pixels, axis=0)
281
  median_color = np.clip(median_color, 0, 255).astype(np.uint8)
282
+
283
+ # Also compute mean for comparison
284
+ mean_color = np.mean(skin_pixels, axis=0)
285
+ mean_color = np.clip(mean_color, 0, 255).astype(np.uint8)
286
+
287
+ # Use median as primary, but fall back to mean if median seems off
288
+ if np.std(median_color) > 100: # If median has high variance
289
+ color_rgb = mean_color
290
+ else:
291
+ color_rgb = median_color
292
+
293
+ r, g, b = int(color_rgb[0]), int(color_rgb[1]), int(color_rgb[2])
294
  hex_code = f"#{r:02X}{g:02X}{b:02X}"
295
+ return hex_code, color_rgb
296
 
297
 
298
  def _solid_color_image(color_rgb: np.ndarray, size: Tuple[int, int] = (160, 160)) -> np.ndarray:
 
302
 
303
 
304
  def detect_skin_tone(image: np.ndarray) -> Tuple[str, np.ndarray, np.ndarray]:
305
+ """Main pipeline: returns (hex_code, color_swatch_image, debug_mask_overlay)."""
306
+ try:
307
+ # Preprocess image
308
+ rgb = _preprocess_image(image)
309
+ height, width = rgb.shape[:2]
310
+
311
+ # Create debug image
312
+ debug_img = rgb.copy()
313
+
314
+ # Try multiple face detection methods
315
+ face_bbox = None
316
+ detection_method = ""
317
+
318
+ # Method 1: MediaPipe (primary)
319
+ face_bbox = _detect_face_bbox_mediapipe(rgb)
320
+ if face_bbox is not None:
321
+ detection_method = "MediaPipe"
322
+
323
+ # Method 2: OpenCV Haar Cascade (fallback)
324
+ if face_bbox is None:
325
+ face_bbox = _detect_face_bbox_opencv(rgb)
326
+ if face_bbox is not None:
327
+ detection_method = "OpenCV Haar"
328
+
329
+ # Method 3: Central crop (last resort)
330
+ if face_bbox is None:
331
+ face_bbox = _central_crop_bbox(width, height, frac=0.5)
332
+ detection_method = "Central Crop"
333
+ print(f"Warning: Using central crop as fallback")
334
+
335
+ x1, y1, x2, y2 = face_bbox
336
+
337
+ # Ensure bbox is valid and not too small
338
+ if x2 <= x1 or y2 <= y1:
339
+ raise ValueError("Invalid bounding box coordinates")
340
+
341
+ if (x2 - x1) < 20 or (y2 - y1) < 20:
342
+ raise ValueError("Face region too small")
343
+
344
+ # Crop face region
345
+ face_crop = rgb[y1:y2, x1:x2]
346
+
347
+ if face_crop.size == 0:
348
+ raise ValueError("Empty face crop")
349
+
350
+ # Draw detection box on debug image
351
+ color = (0, 255, 0) if detection_method != "Central Crop" else (255, 0, 0)
352
+ cv2.rectangle(debug_img, (x1, y1), (x2, y2), color, 2)
353
+ cv2.putText(debug_img, detection_method, (x1, y1 - 10),
354
+ cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 2)
355
+
356
+ # Face parsing segmentation to get skin mask
357
+ try:
358
+ labels, id2label = _segment_face_labels(face_crop)
359
+ skin_indices = _skin_indices_from_id2label(id2label)
360
+
361
+ if not skin_indices:
362
+ # Create a simple central mask as fallback
363
+ h, w = face_crop.shape[:2]
364
+ skin_mask = np.zeros((h, w), dtype=np.uint8)
365
+ center_y, center_x = h // 2, w // 2
366
+ mask_size = min(h, w) // 3
367
+ cv2.ellipse(skin_mask, (center_x, center_y),
368
+ (mask_size, mask_size // 2), 0, 0, 360, 255, -1)
369
+ else:
370
+ skin_mask = np.isin(labels, np.array(skin_indices, dtype=np.int32)).astype(np.uint8) * 255
371
+
372
+ # Clean up the mask
373
+ skin_mask = _binary_open_close(skin_mask, kernel_size=3, iterations=1)
374
+
375
+ except Exception as e:
376
+ print(f"Face parsing error: {e}")
377
+ # Create a simple elliptical mask
378
+ h, w = face_crop.shape[:2]
379
+ skin_mask = np.zeros((h, w), dtype=np.uint8)
380
+ center_y, center_x = h // 2, w // 2
381
+ mask_size = min(h, w) // 3
382
+ cv2.ellipse(skin_mask, (center_x, center_y),
383
+ (mask_size, mask_size // 2), 0, 0, 360, 255, -1)
384
+
385
+ # Ensure we have some skin pixels
386
+ if np.sum(skin_mask) == 0:
387
+ # Use entire face crop as fallback
388
+ skin_mask = np.ones((face_crop.shape[0], face_crop.shape[1]), dtype=np.uint8) * 255
389
+
390
+ # Compute skin color
391
+ hex_code, color_rgb = _compute_skin_color_hex(face_crop, skin_mask)
392
+
393
+ # Prepare swatch
394
+ swatch = _solid_color_image(color_rgb)
395
+
396
+ # Create mask overlay for debug
397
+ full_mask = np.zeros((height, width), dtype=np.uint8)
398
+ full_mask[y1:y2, x1:x2] = skin_mask
399
+
400
+ # Create colored mask
401
+ color_mask = np.zeros_like(rgb)
402
+ color_mask[:, :, 0] = 0 # Red channel
403
+ color_mask[:, :, 1] = 255 # Green channel for skin mask
404
+ color_mask[:, :, 2] = 0 # Blue channel
405
+
406
+ # Apply mask
407
+ mask_3d = np.stack([full_mask] * 3, axis=2) / 255.0
408
+ overlay = (rgb * (1 - mask_3d) + color_mask * mask_3d).astype(np.uint8)
409
+
410
+ # Add hex code to debug image
411
+ cv2.putText(debug_img, hex_code, (10, 30),
412
+ cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 255, 255), 2)
413
+ cv2.putText(debug_img, hex_code, (10, 30),
414
+ cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 0), 1)
415
+
416
+ return hex_code, swatch, debug_img
417
+
418
+ except Exception as e:
419
+ error_msg = f"Error: {str(e)}"
420
+ print(error_msg)
421
+ # Return error state
422
+ error_color = np.array([255, 0, 0], dtype=np.uint8) # Red for error
423
+ error_hex = "#FF0000"
424
+ error_swatch = _solid_color_image(error_color)
425
+
426
+ # Create error debug image
427
+ if 'rgb' in locals():
428
+ error_debug = rgb.copy()
429
+ else:
430
+ error_debug = np.zeros((300, 300, 3), dtype=np.uint8)
431
+ error_debug[:] = [100, 100, 100]
432
+
433
+ cv2.putText(error_debug, "ERROR", (50, 100),
434
+ cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 0, 255), 3)
435
+ cv2.putText(error_debug, error_msg[:30], (50, 150),
436
+ cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2)
437
+
438
+ return error_hex, error_swatch, error_debug
439
 
440
 
441
  def _hex_html(hex_code: str) -> str:
 
443
  "display:flex;align-items:center;gap:12px;padding:8px 0;"
444
  )
445
  swatch_style = (
446
+ f"width:24px;height:24px;border-radius:4px;background:{hex_code};"
447
+ "border:2px solid #333;box-shadow:2px 2px 5px rgba(0,0,0,0.2);"
448
  )
449
  return (
450
  f"<div style='{style}'>"
451
  f"<div style='{swatch_style}'></div>"
452
+ f"<span style='font-family:monospace;font-size:18px;font-weight:bold;'>{hex_code}</span>"
453
  "</div>"
454
  )
455
 
456
 
457
+ # Create Gradio interface
458
+ with gr.Blocks(title="Skin Tone Detector", theme=gr.themes.Soft()) as demo:
459
  gr.Markdown(
460
  """
461
+ # 🎨 Skin Tone Hex Detector
462
+
463
+ Upload a photo with a face to detect the skin tone color. The app will return a HEX color code.
464
+
465
+ ### How it works:
466
+ 1. Face detection using MediaPipe/OpenCV
467
+ 2. Skin region segmentation using AI
468
+ 3. Color extraction from skin pixels
469
+ 4. HEX code generation
470
+
471
+ **Tip:** Use clear, well-lit frontal face photos for best results.
472
  """
473
  )
474
+
475
  with gr.Row():
476
+ with gr.Column(scale=1):
477
  input_image = gr.Image(
478
+ label="πŸ“· Upload Face Image",
479
  type="numpy",
480
  image_mode="RGB",
481
+ height=400,
482
+ sources=["upload", "webcam"],
483
+ interactive=True
484
  )
485
+
486
+ with gr.Row():
487
+ run_btn = gr.Button("πŸ” Detect Skin Tone", variant="primary", size="lg")
488
+ clear_btn = gr.Button("πŸ—‘οΈ Clear", variant="secondary")
489
+
490
+ with gr.Column(scale=1):
491
+ with gr.Group():
492
+ hex_output = gr.HTML(
493
+ label="🎨 Detected Color",
494
+ value="<div style='text-align:center;padding:20px;'>Upload an image to begin</div>"
495
+ )
496
+ swatch_output = gr.Image(
497
+ label="Color Swatch",
498
+ type="numpy",
499
+ height=200,
500
+ interactive=False
501
+ )
502
+
503
+ with gr.Accordion("πŸ” Debug View", open=False):
504
+ debug_output = gr.Image(
505
+ label="Detection Visualization",
506
+ type="numpy",
507
+ height=400,
508
+ interactive=False
509
+ )
510
+
511
+ gr.Markdown("""
512
+ **Detection Legend:**
513
+ - 🟒 Green box: Face detected (MediaPipe/OpenCV)
514
+ - πŸ”΄ Red box: Central crop (fallback)
515
+ - 🟑 Yellow overlay: Skin mask
516
+ """)
517
+
518
+ gr.Markdown("""
519
+ ### πŸ“ Notes:
520
+ - Works best with frontal face photos in good lighting
521
+ - Multiple detection methods ensure reliability
522
+ - Results may vary based on lighting and image quality
523
+ - The HEX code represents the median skin color from detected regions
524
+ """)
525
+
526
  def _run(image: Optional[np.ndarray]):
527
  if image is None:
528
+ return (
529
+ "<div style='text-align:center;padding:20px;color:#666;'>"
530
+ "Please upload an image first</div>",
531
+ np.zeros((200, 200, 3), dtype=np.uint8),
532
+ np.zeros((400, 400, 3), dtype=np.uint8)
533
+ )
534
+
535
+ try:
536
+ hex_code, swatch, debug = detect_skin_tone(image)
537
+ return _hex_html(hex_code), swatch, debug
538
+ except Exception as e:
539
+ error_html = f"""
540
+ <div style='text-align:center;padding:20px;color:#d00;'>
541
+ <h3>❌ Error</h3>
542
+ <p>{str(e)[:100]}...</p>
543
+ <p>Please try a different image.</p>
544
+ </div>
545
+ """
546
+ error_img = np.zeros((200, 200, 3), dtype=np.uint8)
547
+ error_img[:] = [255, 200, 200] # Light red
548
+ return error_html, error_img, None
549
+
550
+ def _clear():
551
+ return (
552
+ None,
553
+ "<div style='text-align:center;padding:20px;color:#666;'>"
554
+ "Upload an image to begin</div>",
555
+ np.zeros((200, 200, 3), dtype=np.uint8),
556
+ np.zeros((400, 400, 3), dtype=np.uint8)
557
+ )
558
+
559
+ # Connect events
560
+ run_btn.click(
561
+ fn=_run,
562
+ inputs=[input_image],
563
+ outputs=[hex_output, swatch_output, debug_output]
564
+ )
565
+
566
+ clear_btn.click(
567
+ fn=_clear,
568
+ inputs=[],
569
+ outputs=[input_image, hex_output, swatch_output, debug_output]
570
+ )
571
 
572
 
573
  if __name__ == "__main__":
574
+ # Print startup message
575
+ print("=" * 60)
576
+ print("πŸš€ Starting Skin Tone Detector")
577
+ print("=" * 60)
578
+ print("\nAccess the app at: http://localhost:7860")
579
+ print("\nPress Ctrl+C to stop the server")
580
+
581
+ # Launch with better settings
582
+ demo.launch(
583
+ server_name="0.0.0.0",
584
+ server_port=7860,
585
+ share=False,
586
+ debug=False,
587
+ show_error=True,
588
+ quiet=False
589
+ )