Stylique commited on
Commit
6d863be
·
verified ·
1 Parent(s): d05767e

Upload 2 files

Browse files
Files changed (2) hide show
  1. app.py +241 -0
  2. requirements.txt +5 -0
app.py ADDED
@@ -0,0 +1,241 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import base64
2
+ from io import BytesIO
3
+ from typing import Tuple, Optional
4
+
5
+ import cv2
6
+ import gradio as gr
7
+ import numpy as np
8
+ from PIL import Image
9
+
10
+
11
+ def _ensure_rgb_uint8(image: np.ndarray) -> np.ndarray:
12
+ """Convert an input image array to RGB uint8 format.
13
+
14
+ Gradio provides images as numpy arrays in RGB order with dtype uint8 by default,
15
+ but we defensively normalize here in case inputs vary.
16
+ """
17
+ if image is None:
18
+ raise ValueError("No image provided")
19
+
20
+ if isinstance(image, Image.Image):
21
+ image = np.array(image.convert("RGB"))
22
+ elif image.dtype != np.uint8:
23
+ image = image.astype(np.uint8)
24
+
25
+ if image.ndim == 2:
26
+ image = cv2.cvtColor(image, cv2.COLOR_GRAY2RGB)
27
+ elif image.shape[2] == 4:
28
+ image = cv2.cvtColor(image, cv2.COLOR_RGBA2RGB)
29
+ return image
30
+
31
+
32
+ def _central_crop_bbox(width: int, height: int, frac: float = 0.6) -> Tuple[int, int, int, int]:
33
+ """Return a central crop bounding box (x1, y1, x2, y2) covering `frac` of width/height."""
34
+ frac = float(np.clip(frac, 0.2, 1.0))
35
+ crop_w = int(width * frac)
36
+ crop_h = int(height * frac)
37
+ x1 = (width - crop_w) // 2
38
+ y1 = (height - crop_h) // 2
39
+ x2 = x1 + crop_w
40
+ y2 = y1 + crop_h
41
+ return x1, y1, x2, y2
42
+
43
+
44
+ def _binary_open_close(mask: np.ndarray, kernel_size: int = 5, iterations: int = 1) -> np.ndarray:
45
+ """Apply morphological open then close to clean the binary mask."""
46
+ kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (kernel_size, kernel_size))
47
+ opened = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel, iterations=iterations)
48
+ closed = cv2.morphologyEx(opened, cv2.MORPH_CLOSE, kernel, iterations=iterations)
49
+ return closed
50
+
51
+
52
+ def _skin_mask_ycrcb(image_rgb: np.ndarray) -> np.ndarray:
53
+ """Skin detection using YCrCb thresholding.
54
+
55
+ Returns a binary mask (uint8 0/255) where 255 denotes skin-like pixels.
56
+ Thresholds are chosen to be reasonably inclusive for diverse skin tones.
57
+ """
58
+ image_ycrcb = cv2.cvtColor(image_rgb, cv2.COLOR_RGB2YCrCb)
59
+ Y, Cr, Cb = cv2.split(image_ycrcb)
60
+
61
+ # Typical skin ranges in YCrCb space
62
+ cr_min, cr_max = 133, 180
63
+ cb_min, cb_max = 77, 140
64
+
65
+ mask_cr = cv2.inRange(Cr, cr_min, cr_max)
66
+ mask_cb = cv2.inRange(Cb, cb_min, cb_max)
67
+ mask = cv2.bitwise_and(mask_cr, mask_cb)
68
+
69
+ mask = _binary_open_close(mask, kernel_size=5, iterations=1)
70
+ mask = cv2.GaussianBlur(mask, (5, 5), 0)
71
+ _, mask = cv2.threshold(mask, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
72
+ return mask
73
+
74
+
75
+ def _skin_mask_hsv(image_rgb: np.ndarray) -> np.ndarray:
76
+ """Auxiliary HSV-based skin detection mask."""
77
+ image_hsv = cv2.cvtColor(image_rgb, cv2.COLOR_RGB2HSV)
78
+ H, S, V = cv2.split(image_hsv)
79
+
80
+ # Skin hues tend to be in the lower range; saturation moderate; value reasonably bright
81
+ h_min, h_max = 0, 50
82
+ s_min, s_max = int(0.20 * 255), int(0.80 * 255)
83
+ v_min = int(0.20 * 255)
84
+
85
+ mask_h = cv2.inRange(H, h_min, h_max)
86
+ mask_s = cv2.inRange(S, s_min, s_max)
87
+ mask_v = cv2.inRange(V, v_min, 255)
88
+ mask = cv2.bitwise_and(cv2.bitwise_and(mask_h, mask_s), mask_v)
89
+ mask = _binary_open_close(mask, kernel_size=5, iterations=1)
90
+ return mask
91
+
92
+
93
+ def _combine_masks(mask1: np.ndarray, mask2: np.ndarray) -> np.ndarray:
94
+ if mask1 is None:
95
+ return mask2
96
+ if mask2 is None:
97
+ return mask1
98
+ combined = cv2.bitwise_and(mask1, mask2)
99
+ return combined
100
+
101
+
102
+ def _compute_skin_color_hex(image_rgb: np.ndarray, mask: np.ndarray) -> Tuple[str, np.ndarray]:
103
+ """Compute a robust representative skin color as a hex string and return also the RGB color.
104
+
105
+ Uses median across masked pixels to reduce influence of highlights/shadows.
106
+ """
107
+ if mask is None or mask.size == 0:
108
+ raise ValueError("Invalid mask for skin color computation")
109
+
110
+ # boolean mask for indexing
111
+ mask_bool = mask.astype(bool)
112
+ if not np.any(mask_bool):
113
+ raise ValueError("No skin pixels detected")
114
+
115
+ skin_pixels = image_rgb[mask_bool]
116
+
117
+ # Robust median to mitigate outliers
118
+ median_color = np.median(skin_pixels, axis=0)
119
+ median_color = np.clip(median_color, 0, 255).astype(np.uint8)
120
+
121
+ r, g, b = int(median_color[0]), int(median_color[1]), int(median_color[2])
122
+ hex_code = f"#{r:02X}{g:02X}{b:02X}"
123
+ return hex_code, median_color
124
+
125
+
126
+ def _solid_color_image(color_rgb: np.ndarray, size: Tuple[int, int] = (160, 160)) -> np.ndarray:
127
+ swatch = np.zeros((size[1], size[0], 3), dtype=np.uint8)
128
+ swatch[:, :] = color_rgb
129
+ return swatch
130
+
131
+
132
+ def detect_skin_tone(image: np.ndarray, center_focus: bool = True) -> Tuple[str, np.ndarray, np.ndarray]:
133
+ """Main pipeline: returns (hex_code, color_swatch_image, debug_mask_overlay).
134
+
135
+ - image: input image as numpy array (H, W, 3) RGB uint8
136
+ - center_focus: if True, prioritizes central crop region to avoid background/hands
137
+ """
138
+ rgb = _ensure_rgb_uint8(image)
139
+ height, width = rgb.shape[:2]
140
+
141
+ # Optionally restrict to central crop to avoid background
142
+ if center_focus:
143
+ x1, y1, x2, y2 = _central_crop_bbox(width, height, frac=0.7)
144
+ central_rgb = rgb[y1:y2, x1:x2]
145
+ else:
146
+ x1, y1, x2, y2 = 0, 0, width, height
147
+ central_rgb = rgb
148
+
149
+ mask_ycrcb = _skin_mask_ycrcb(central_rgb)
150
+ mask_hsv = _skin_mask_hsv(central_rgb)
151
+ combined_mask = _combine_masks(mask_ycrcb, mask_hsv)
152
+
153
+ # If too few pixels, relax to YCrCb only
154
+ if np.count_nonzero(combined_mask) < 100:
155
+ combined_mask = mask_ycrcb
156
+
157
+ # If still too few, fallback to a small central patch without masking
158
+ if np.count_nonzero(combined_mask) < 100:
159
+ patch_frac = 0.2
160
+ px1, py1, px2, py2 = _central_crop_bbox(central_rgb.shape[1], central_rgb.shape[0], frac=patch_frac)
161
+ patch = central_rgb[py1:py2, px1:px2]
162
+ median_color = np.median(patch.reshape(-1, 3), axis=0).astype(np.uint8)
163
+ r, g, b = int(median_color[0]), int(median_color[1]), int(median_color[2])
164
+ hex_code = f"#{r:02X}{g:02X}{b:02X}"
165
+
166
+ # Build outputs
167
+ swatch = _solid_color_image(median_color)
168
+ # Debug overlay: show the central patch
169
+ debug_overlay = rgb.copy()
170
+ cv2.rectangle(debug_overlay, (x1 + px1, y1 + py1), (x1 + px2, y1 + py2), (255, 0, 0), 2)
171
+ return hex_code, swatch, debug_overlay
172
+
173
+ # Compute color from masked central region
174
+ hex_code, color_rgb = _compute_skin_color_hex(central_rgb, combined_mask)
175
+
176
+ # Prepare swatch and debug visualization
177
+ swatch = _solid_color_image(color_rgb)
178
+
179
+ # Place mask back into full image coordinates for visualization
180
+ full_mask = np.zeros((height, width), dtype=np.uint8)
181
+ full_mask[y1:y2, x1:x2] = combined_mask
182
+ color_mask = cv2.cvtColor(full_mask, cv2.COLOR_GRAY2RGB)
183
+ overlay = cv2.addWeighted(rgb, 0.8, color_mask, 0.2, 0)
184
+
185
+ return hex_code, swatch, overlay
186
+
187
+
188
+ def _hex_html(hex_code: str) -> str:
189
+ style = (
190
+ "display:flex;align-items:center;gap:12px;padding:8px 0;"
191
+ )
192
+ swatch_style = (
193
+ f"width:20px;height:20px;border-radius:4px;background:{hex_code};"
194
+ "border:1px solid #ccc;"
195
+ )
196
+ return (
197
+ f"<div style='{style}'>"
198
+ f"<div style='{swatch_style}'></div>"
199
+ f"<span style='font-family:monospace;font-size:16px'>{hex_code}</span>"
200
+ "</div>"
201
+ )
202
+
203
+
204
+ with gr.Blocks(title="Skin Tone Detector") as demo:
205
+ gr.Markdown(
206
+ """
207
+ ### Skin Tone Hex Detector
208
+ Upload a face image. The app estimates a representative skin tone and returns a HEX color.
209
+ """
210
+ )
211
+
212
+ with gr.Row():
213
+ with gr.Column():
214
+ input_image = gr.Image(
215
+ label="Upload face image",
216
+ type="numpy",
217
+ image_mode="RGB",
218
+ height=360,
219
+ )
220
+ center_focus = gr.Checkbox(value=True, label="Center focus (ignore edges)")
221
+ run_btn = gr.Button("Detect Skin Tone", variant="primary")
222
+
223
+ with gr.Column():
224
+ hex_output = gr.HTML(label="HEX Color")
225
+ swatch_output = gr.Image(label="Color Swatch", type="numpy")
226
+ debug_output = gr.Image(label="Mask Overlay", type="numpy")
227
+
228
+ def _run(image: Optional[np.ndarray], center_focus: bool):
229
+ if image is None:
230
+ return _hex_html("#000000"), np.zeros((160, 160, 3), dtype=np.uint8), None
231
+ hex_code, swatch, debug = detect_skin_tone(image, center_focus=center_focus)
232
+ return _hex_html(hex_code), swatch, debug
233
+
234
+ run_btn.click(_run, inputs=[input_image, center_focus], outputs=[hex_output, swatch_output, debug_output])
235
+ input_image.change(_run, inputs=[input_image, center_focus], outputs=[hex_output, swatch_output, debug_output])
236
+
237
+
238
+ if __name__ == "__main__":
239
+ demo.launch()
240
+
241
+
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ gradio>=4.44.0
2
+ opencv-python-headless>=4.10.0.84
3
+ numpy>=1.26.0
4
+ Pillow>=10.3.0
5
+