Files changed (1) hide show
  1. app.py +135 -119
app.py CHANGED
@@ -19,6 +19,7 @@ import io
19
  import asyncio
20
  from concurrent.futures import ThreadPoolExecutor
21
  import logging
 
22
  logging.basicConfig(level=logging.INFO)
23
  logger = logging.getLogger(__name__)
24
 
@@ -26,12 +27,15 @@ logger = logging.getLogger(__name__)
26
  # 2. CONFIG
27
  # ================================================
28
  BEARD_MODEL_PATH = "models/best_hair_117_epoch_v4.pt"
29
- SAFE_IMG_SIZE = 768
30
  DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
31
- USE_FP16 = DEVICE.type == "cuda" and torch.cuda.is_available()
 
32
  logger.info(f"🚀 Device: {DEVICE}, FP16: {USE_FP16}")
 
33
  os.environ["HF_HOME"] = "/tmp/hf_cache"
34
  os.makedirs("/tmp/hf_cache", exist_ok=True)
 
35
  executor = ThreadPoolExecutor(max_workers=2)
36
 
37
  face_processor = None
@@ -45,17 +49,20 @@ def load_face_parser():
45
  global face_processor, face_parser
46
  if face_parser is not None:
47
  return face_processor, face_parser
48
-
49
- logger.info("Loading Segformer face-parsing...")
50
  face_processor = SegformerImageProcessor.from_pretrained("jonathandinu/face-parsing")
51
  face_parser = SegformerForSemanticSegmentation.from_pretrained("jonathandinu/face-parsing")
 
52
  face_parser.to(DEVICE)
53
  face_parser.eval()
54
  if USE_FP16:
55
  face_parser = face_parser.half()
 
56
  logger.info("✅ Face parser loaded!")
57
  return face_processor, face_parser
58
 
 
59
  def load_beard_model():
60
  global beard_model
61
  if beard_model is None:
@@ -66,235 +73,243 @@ def load_beard_model():
66
  return beard_model
67
 
68
  # ================================================
69
- # 4. EXCLUDE MASK (NOSE + LIPS) - BEARD SE HATANA
70
  # ================================================
71
- def get_exclude_mask(pil_image: Image.Image, max_size=512) -> np.ndarray:
72
- """Returns binary mask for nose (2) and lips (11,12) at original size."""
73
  processor, parser = load_face_parser()
74
 
75
  w, h = pil_image.size
76
- if max(w, h) > max_size:
77
- ratio = max_size / max(w, h)
78
- new_size = (int(w * ratio), int(h * ratio))
79
- img_resized = pil_image.resize(new_size, Image.LANCZOS)
80
- else:
81
- img_resized = pil_image
82
 
83
- inputs = processor(images=img_resized, return_tensors="pt").to(DEVICE)
84
  if USE_FP16:
85
  inputs['pixel_values'] = inputs['pixel_values'].half()
86
-
87
  with torch.no_grad():
88
  out = parser(**inputs)
 
 
 
 
 
 
 
 
89
 
90
- logits = out.logits
91
- up = torch.nn.functional.interpolate(logits, size=img_resized.size[::-1], mode="bilinear", align_corners=False)
92
- probs = torch.softmax(up.float() if USE_FP16 else up, dim=1)[0]
93
-
94
- # Nose (class 2), Upper lip (11), Lower lip (12)
95
- nose = (probs[2].cpu().numpy() > 0.5).astype(np.float32)
96
- lip_upper = (probs[11].cpu().numpy() > 0.5).astype(np.float32)
97
- lip_lower = (probs[12].cpu().numpy() > 0.5).astype(np.float32)
98
- exclude = np.clip(nose + lip_upper + lip_lower, 0, 1)
99
 
100
- # Resize back to original dimensions
101
  exclude = cv2.resize(exclude, (w, h), interpolation=cv2.INTER_NEAREST)
102
- # Strong dilation to remove half lips and nose edges completely
103
- kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (7, 7))
104
- exclude = cv2.dilate(exclude, kernel, iterations=2)
105
  return exclude
106
 
107
  # ================================================
108
- # 5. MASK FUNCTIONS (BEARD EXCLUDES NOSE + LIPS)
109
  # ================================================
110
  def get_beard_mask_fast(pil_image: Image.Image) -> np.ndarray:
111
  temp = f"temp_{uuid.uuid4().hex[:8]}.jpg"
112
  try:
113
  img_small = pil_image.resize((256, 256), Image.LANCZOS)
114
  img_small.save(temp)
 
115
  model = load_beard_model()
116
- res = model(temp, device=DEVICE.type, conf=0.3, iou=0.5, verbose=False, half=USE_FP16, imgsz=256)
117
-
 
118
  h, w = np.array(pil_image).shape[:2]
119
  mask = np.zeros((h, w), dtype=np.float32)
120
-
121
  if res[0].masks is not None:
122
  for i, cls in enumerate(res[0].boxes.cls):
123
  if int(cls) == 0: # beard class
124
- m = cv2.resize(res[0].masks.data[i].cpu().numpy(), (w, h))
125
- mask = np.maximum(mask, (m > 0.45).astype(np.float32))
126
-
127
- # ========== REMOVE NOSE AND LIPS FROM BEARD MASK ==========
128
- exclude_mask = get_exclude_mask(pil_image) # nose + lips
129
- mask = np.maximum(mask - exclude_mask, 0.0)
130
-
 
131
  if np.sum(mask) == 0:
132
  return mask
133
-
 
134
  kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3))
135
  mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel, iterations=1)
136
- mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel, iterations=1)
137
- mask = cv2.GaussianBlur(mask, (5, 5), 1)
138
-
139
  return np.clip(mask, 0, 1)
140
  finally:
141
  if os.path.exists(temp):
142
  os.remove(temp)
143
 
 
144
  def get_hair_mask_fast(pil_image: Image.Image) -> np.ndarray:
145
- """Fixed & Improved version - Now properly captures forehead, hairline & thin hairs"""
146
  processor, parser = load_face_parser()
 
147
  img_small = pil_image.resize((256, 256), Image.LANCZOS)
148
  inputs = processor(images=img_small, return_tensors="pt").to(DEVICE)
 
149
  if USE_FP16:
150
  inputs['pixel_values'] = inputs['pixel_values'].half()
 
151
  with torch.no_grad():
152
  out = parser(**inputs)
153
- logits = out.logits
154
- up = torch.nn.functional.interpolate(logits, size=img_small.size[::-1], mode="bilinear", align_corners=False)
155
- probs = torch.softmax(up.float() if USE_FP16 else up, dim=1)[0]
156
- # ================== STRONGER HAIR CAPTURE FOR FOREHEAD & THIN HAIRS ==================
157
- strong_hair = (probs[13].cpu().numpy() > 0.055).astype(np.float32)
158
- soft_hair = (probs[13].cpu().numpy() > 0.022).astype(np.float32) # Very fine hairs
 
 
159
  hair = np.maximum(strong_hair, soft_hair * 0.68)
160
- # Face mask - exclude inner face but much softer on forehead & hairline
 
161
  face_cls = list(range(1, 6)) + list(range(8, 13)) + [17, 18]
162
  parsing = up.argmax(dim=1).squeeze(0).cpu().numpy()
163
  face_m = np.isin(parsing, face_cls).astype(np.float32)
164
- # Very light dilation so forehead hairs are not erased
165
  kernel_face = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3))
166
  face_m = cv2.dilate(face_m, kernel_face, iterations=1)
167
- # Special relaxation for forehead area (top part of image)
 
168
  h, w = face_m.shape
169
  forehead_region = np.zeros_like(face_m)
170
- forehead_region[0:int(h * 0.30), :] = 1.0 # Top 30% as forehead
171
- face_m = face_m * (1 - forehead_region * 0.45) # Reduce face suppression in forehead
 
172
  hair = hair * (1 - face_m)
173
- # Morphology - better connection for scattered forehead hairs
 
174
  kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3))
175
  hair = cv2.morphologyEx(hair, cv2.MORPH_OPEN, kernel, iterations=1)
176
- hair = cv2.morphologyEx(hair, cv2.MORPH_CLOSE, kernel, iterations=3) # Increased for better thin hair connection
177
- # Smoother blend
178
- hair = cv2.GaussianBlur(hair, (5, 5), 1.8)
179
- oh, ow = np.array(pil_image).shape[:2]
 
180
  return np.clip(cv2.resize(hair, (ow, oh)), 0, 1)
181
 
 
182
  def get_masks_sequential(image):
183
- return get_hair_mask_fast(image), get_beard_mask_fast(image)
 
 
184
 
185
  # ================================================
186
- # 6. GREY HAIR & BEARD - EXACT COLOR TRANSFER
187
  # ================================================
188
  def apply_strong_grey_hair(image: Image.Image, hair_mask: np.ndarray, beard_mask: np.ndarray) -> Image.Image:
189
- """Beard color exactly matches hair using LAB color transfer"""
190
  comb = np.maximum(hair_mask, beard_mask)
191
- if np.sum(comb) < 100:
192
- logger.warning("⚠️ Small mask area")
193
-
194
- # Soften edges
195
- comb = cv2.GaussianBlur(comb, (7, 7), 2)
196
  img = np.array(image).astype(np.float32) / 255.0
197
-
198
- # Step 1: Apply grey transformation to hair area only
199
  hsv = cv2.cvtColor((img * 255).astype(np.uint8), cv2.COLOR_RGB2HSV).astype(np.float32)
200
  hsv_hair = hsv.copy()
201
- # Desaturate and boost brightness only on hair
202
- # Gentler grey transformation
203
- saturation_factor = 0.8 # Less aggressive (was 1.0)
204
- brightness_boost = 90 # Less boost (was 110)
205
-
206
  hsv_hair[:,:,1] = hsv_hair[:,:,1] * (1 - saturation_factor * hair_mask)
207
- hsv_hair[:,:,2] = hsv_hair[:,:,2] + (brightness_boost * hair_mask)
208
- hsv_hair[:,:,2] = np.clip(hsv_hair[:,:,2], 100, 200)
209
- hair_grey = cv2.cvtColor(hsv_hair.astype(np.uint8), cv2.COLOR_HSV2RGB).astype(np.float32) / 255.0
210
 
211
- # Step 2: Transfer color from hair_grey to beard area (preserving beard texture)
212
- # Convert to LAB color space
 
213
  hair_lab = cv2.cvtColor((hair_grey * 255).astype(np.uint8), cv2.COLOR_RGB2LAB).astype(np.float32)
214
  img_lab = cv2.cvtColor((img * 255).astype(np.uint8), cv2.COLOR_RGB2LAB).astype(np.float32)
215
-
216
- # Get hair region pixels in LAB
217
  hair_mask_binary = (hair_mask > 0.5)
218
- if np.sum(hair_mask_binary) > 100:
219
- hair_lab_pixels = hair_lab[hair_mask_binary]
220
- mean_hair_lab = np.mean(hair_lab_pixels, axis=0)
221
- std_hair_lab = np.std(hair_lab_pixels, axis=0)
222
  else:
223
- # fallback grey
224
  mean_hair_lab = np.array([128, 0, 0])
225
- std_hair_lab = np.array([30, 10, 10])
226
-
227
- # Apply to beard region
228
  beard_mask_binary = (beard_mask > 0.5)
229
  if np.sum(beard_mask_binary) > 0:
230
  beard_pixels_lab = img_lab[beard_mask_binary]
231
- mean_beard_lab = np.mean(beard_pixels_lab, axis=0)
232
- std_beard_lab = np.std(beard_pixels_lab, axis=0)
233
 
234
- # Avoid division by zero
235
- std_beard_lab = np.maximum(std_beard_lab, 1e-5)
236
- # Transfer: (beard - mean_beard)/std_beard * std_hair + mean_hair
237
- beard_norm = (beard_pixels_lab - mean_beard_lab) / std_beard_lab
238
  beard_transfer = beard_norm * std_hair_lab + mean_hair_lab
239
  beard_transfer = np.clip(beard_transfer, 0, 255)
240
-
241
- # Put back
242
  img_lab_transfer = img_lab.copy()
243
  img_lab_transfer[beard_mask_binary] = beard_transfer
244
  else:
245
  img_lab_transfer = img_lab
246
-
247
- # Convert back to RGB
248
  final = cv2.cvtColor(img_lab_transfer.astype(np.uint8), cv2.COLOR_LAB2RGB).astype(np.float32) / 255.0
249
-
250
- # Where hair is, use hair_grey
251
  hair_mask_3ch = np.stack([hair_mask, hair_mask, hair_mask], axis=2)
252
  final = hair_grey * hair_mask_3ch + final * (1 - hair_mask_3ch)
253
-
254
- # Blend edges
255
  comb_3ch = np.stack([comb, comb, comb], axis=2)
256
  final = final * comb_3ch + img * (1 - comb_3ch)
257
-
258
- # Slight warm tint (optional)
259
  warm = np.array([5, 3, 0], dtype=np.float32) / 255.0
260
- final = final + (warm * comb[..., None] * 0.2)
261
  final = np.clip(final * 255, 0, 255).astype(np.uint8)
262
-
263
  result = Image.fromarray(final)
264
- result = result.filter(ImageFilter.UnsharpMask(radius=0.5, percent=50, threshold=0))
265
  return result
266
 
 
267
  def process_face_whitening(input_image: Image.Image) -> Image.Image:
268
- """Main processing function"""
269
  try:
270
  logger.info(f"→ Processing image: {input_image.size}")
271
  orig = input_image.convert("RGB")
272
  ow, oh = orig.size
 
273
  target_size = min(SAFE_IMG_SIZE, max(ow, oh))
274
  if target_size % 2 == 1:
275
  target_size -= 1
 
276
  img_resized = orig.resize((target_size, target_size), Image.LANCZOS)
277
- logger.info(" Generating hair & beard masks...")
 
278
  hair_mask, beard_mask = get_masks_sequential(img_resized)
 
279
  logger.info(f"Hair mask sum: {np.sum(hair_mask):.0f}, Beard mask sum: {np.sum(beard_mask):.0f}")
280
- logger.info(" Applying STRONG GREY HAIR with color transfer...")
 
281
  final_img = apply_strong_grey_hair(img_resized, hair_mask, beard_mask)
 
282
  gc.collect()
283
  logger.info("✅ Processing completed!")
284
  return final_img.resize((ow, oh), Image.LANCZOS)
 
285
  except Exception as e:
286
  logger.error(f"❌ Processing error: {e}")
287
  logger.error(traceback.format_exc())
288
  raise HTTPException(status_code=500, detail=f"Processing failed: {str(e)}")
289
 
 
290
  # ================================================
291
  # 7. FASTAPI
292
  # ================================================
293
  app = FastAPI(title="Strong Grey Hair API")
294
- app.add_middleware(CORSMiddleware,
295
- allow_origins=["*"],
296
- allow_credentials=True,
297
- allow_methods=["*"],
 
298
  allow_headers=["*"])
299
 
300
  @app.on_event("startup")
@@ -309,13 +324,13 @@ async def startup_event():
309
  async def age_face(file: UploadFile = File(...)):
310
  if not file.content_type.startswith("image/"):
311
  raise HTTPException(400, "Only image files allowed")
312
-
313
  contents = await file.read()
314
  try:
315
  input_image = Image.open(io.BytesIO(contents)).convert("RGB")
316
  loop = asyncio.get_event_loop()
317
  result = await loop.run_in_executor(executor, process_face_whitening, input_image)
318
-
319
  buf = io.BytesIO()
320
  result.save(buf, format="JPEG", quality=92, optimize=True)
321
  buf.seek(0)
@@ -326,7 +341,8 @@ async def age_face(file: UploadFile = File(...)):
326
  finally:
327
  gc.collect()
328
 
 
329
  if __name__ == "__main__":
330
  import uvicorn
331
  logger.info("🚀 Starting STRONG GREY HAIR server on port 7860")
332
- uvicorn.run(app, host="0.0.0.0", port=7860)
 
19
  import asyncio
20
  from concurrent.futures import ThreadPoolExecutor
21
  import logging
22
+
23
  logging.basicConfig(level=logging.INFO)
24
  logger = logging.getLogger(__name__)
25
 
 
27
  # 2. CONFIG
28
  # ================================================
29
  BEARD_MODEL_PATH = "models/best_hair_117_epoch_v4.pt"
30
+ SAFE_IMG_SIZE = 640 # Reduced from 768 → big speed gain
31
  DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
32
+ USE_FP16 = DEVICE.type == "cuda"
33
+
34
  logger.info(f"🚀 Device: {DEVICE}, FP16: {USE_FP16}")
35
+
36
  os.environ["HF_HOME"] = "/tmp/hf_cache"
37
  os.makedirs("/tmp/hf_cache", exist_ok=True)
38
+
39
  executor = ThreadPoolExecutor(max_workers=2)
40
 
41
  face_processor = None
 
49
  global face_processor, face_parser
50
  if face_parser is not None:
51
  return face_processor, face_parser
52
+
53
+ logger.info("Loading Segformer face-parsing (FP16)...")
54
  face_processor = SegformerImageProcessor.from_pretrained("jonathandinu/face-parsing")
55
  face_parser = SegformerForSemanticSegmentation.from_pretrained("jonathandinu/face-parsing")
56
+
57
  face_parser.to(DEVICE)
58
  face_parser.eval()
59
  if USE_FP16:
60
  face_parser = face_parser.half()
61
+
62
  logger.info("✅ Face parser loaded!")
63
  return face_processor, face_parser
64
 
65
+
66
  def load_beard_model():
67
  global beard_model
68
  if beard_model is None:
 
73
  return beard_model
74
 
75
  # ================================================
76
+ # 4. LIGHTWEIGHT NOSE + LIPS MASK (bahut important optimization)
77
  # ================================================
78
+ def get_nose_lips_mask(pil_image: Image.Image) -> np.ndarray:
79
+ """Fast nose + lips exclusion mask"""
80
  processor, parser = load_face_parser()
81
 
82
  w, h = pil_image.size
83
+ # Small size for speed
84
+ small = pil_image.resize((192, 192), Image.LANCZOS)
 
 
 
 
85
 
86
+ inputs = processor(images=small, return_tensors="pt").to(DEVICE)
87
  if USE_FP16:
88
  inputs['pixel_values'] = inputs['pixel_values'].half()
89
+
90
  with torch.no_grad():
91
  out = parser(**inputs)
92
+ logits = out.logits
93
+ up = torch.nn.functional.interpolate(logits, size=small.size[::-1],
94
+ mode="bilinear", align_corners=False)
95
+ probs = torch.softmax(up.float() if USE_FP16 else up, dim=1)[0]
96
+
97
+ # Nose (2), Upper lip (11), Lower lip (12)
98
+ nose = (probs[2] > 0.5).cpu().numpy().astype(np.float32)
99
+ lips = ((probs[11] > 0.45) + (probs[12] > 0.45)).cpu().numpy().astype(np.float32)
100
 
101
+ exclude = np.clip(nose + lips, 0, 1)
 
 
 
 
 
 
 
 
102
 
103
+ # Resize to original + light dilation
104
  exclude = cv2.resize(exclude, (w, h), interpolation=cv2.INTER_NEAREST)
105
+ kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
106
+ exclude = cv2.dilate(exclude, kernel, iterations=1)
107
+
108
  return exclude
109
 
110
  # ================================================
111
+ # 5. MASK FUNCTIONS
112
  # ================================================
113
  def get_beard_mask_fast(pil_image: Image.Image) -> np.ndarray:
114
  temp = f"temp_{uuid.uuid4().hex[:8]}.jpg"
115
  try:
116
  img_small = pil_image.resize((256, 256), Image.LANCZOS)
117
  img_small.save(temp)
118
+
119
  model = load_beard_model()
120
+ res = model(temp, device=DEVICE.type, conf=0.25, iou=0.45,
121
+ verbose=False, half=USE_FP16, imgsz=256)
122
+
123
  h, w = np.array(pil_image).shape[:2]
124
  mask = np.zeros((h, w), dtype=np.float32)
125
+
126
  if res[0].masks is not None:
127
  for i, cls in enumerate(res[0].boxes.cls):
128
  if int(cls) == 0: # beard class
129
+ m = cv2.resize(res[0].masks.data[i].cpu().numpy(), (w, h),
130
+ interpolation=cv2.INTER_LINEAR)
131
+ mask = np.maximum(mask, (m > 0.40).astype(np.float32))
132
+
133
+ # Remove nose + lips
134
+ exclude = get_nose_lips_mask(pil_image)
135
+ mask = np.maximum(mask - exclude, 0.0)
136
+
137
  if np.sum(mask) == 0:
138
  return mask
139
+
140
+ # Light morphology + blur
141
  kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3))
142
  mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel, iterations=1)
143
+ mask = cv2.GaussianBlur(mask, (5, 5), 0.8)
144
+
 
145
  return np.clip(mask, 0, 1)
146
  finally:
147
  if os.path.exists(temp):
148
  os.remove(temp)
149
 
150
+
151
  def get_hair_mask_fast(pil_image: Image.Image) -> np.ndarray:
152
+ """Improved + faster hair mask"""
153
  processor, parser = load_face_parser()
154
+
155
  img_small = pil_image.resize((256, 256), Image.LANCZOS)
156
  inputs = processor(images=img_small, return_tensors="pt").to(DEVICE)
157
+
158
  if USE_FP16:
159
  inputs['pixel_values'] = inputs['pixel_values'].half()
160
+
161
  with torch.no_grad():
162
  out = parser(**inputs)
163
+ logits = out.logits
164
+ up = torch.nn.functional.interpolate(logits, size=img_small.size[::-1],
165
+ mode="bilinear", align_corners=False)
166
+ probs = torch.softmax(up.float() if USE_FP16 else up, dim=1)[0]
167
+
168
+ # Strong + soft hair capture (forehead & thin hairs)
169
+ strong_hair = (probs[13] > 0.055).astype(np.float32)
170
+ soft_hair = (probs[13] > 0.022).astype(np.float32)
171
  hair = np.maximum(strong_hair, soft_hair * 0.68)
172
+
173
+ # Face exclusion (softer on forehead)
174
  face_cls = list(range(1, 6)) + list(range(8, 13)) + [17, 18]
175
  parsing = up.argmax(dim=1).squeeze(0).cpu().numpy()
176
  face_m = np.isin(parsing, face_cls).astype(np.float32)
177
+
178
  kernel_face = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3))
179
  face_m = cv2.dilate(face_m, kernel_face, iterations=1)
180
+
181
+ # Forehead relaxation
182
  h, w = face_m.shape
183
  forehead_region = np.zeros_like(face_m)
184
+ forehead_region[0:int(h * 0.32), :] = 1.0
185
+ face_m = face_m * (1 - forehead_region * 0.45)
186
+
187
  hair = hair * (1 - face_m)
188
+
189
+ # Morphology for better connection
190
  kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3))
191
  hair = cv2.morphologyEx(hair, cv2.MORPH_OPEN, kernel, iterations=1)
192
+ hair = cv2.morphologyEx(hair, cv2.MORPH_CLOSE, kernel, iterations=2)
193
+
194
+ hair = cv2.GaussianBlur(hair, (5, 5), 1.5)
195
+
196
+ ow, oh = pil_image.size
197
  return np.clip(cv2.resize(hair, (ow, oh)), 0, 1)
198
 
199
+
200
  def get_masks_sequential(image):
201
+ hair = get_hair_mask_fast(image)
202
+ beard = get_beard_mask_fast(image)
203
+ return hair, beard
204
 
205
  # ================================================
206
+ # 6. GREY HAIR & BEARD COLOR TRANSFER (Optimized)
207
  # ================================================
208
  def apply_strong_grey_hair(image: Image.Image, hair_mask: np.ndarray, beard_mask: np.ndarray) -> Image.Image:
 
209
  comb = np.maximum(hair_mask, beard_mask)
210
+ if np.sum(comb) < 80:
211
+ logger.warning("⚠️ Very small mask area")
212
+
213
+ comb = cv2.GaussianBlur(comb, (7, 7), 2.0)
 
214
  img = np.array(image).astype(np.float32) / 255.0
215
+
216
+ # HSV based grey for hair area (faster)
217
  hsv = cv2.cvtColor((img * 255).astype(np.uint8), cv2.COLOR_RGB2HSV).astype(np.float32)
218
  hsv_hair = hsv.copy()
219
+
220
+ saturation_factor = 0.78
221
+ brightness_boost = 82
 
 
222
  hsv_hair[:,:,1] = hsv_hair[:,:,1] * (1 - saturation_factor * hair_mask)
223
+ hsv_hair[:,:,2] = np.clip(hsv_hair[:,:,2] + (brightness_boost * hair_mask), 95, 205)
 
 
224
 
225
+ hair_grey = cv2.cvtColor(hsv_hair.astype(np.uint8), cv2.COLOR_HSV2RGB).astype(np.float32) / 255.0
226
+
227
+ # Simple LAB color transfer for beard (to match hair tone)
228
  hair_lab = cv2.cvtColor((hair_grey * 255).astype(np.uint8), cv2.COLOR_RGB2LAB).astype(np.float32)
229
  img_lab = cv2.cvtColor((img * 255).astype(np.uint8), cv2.COLOR_RGB2LAB).astype(np.float32)
230
+
 
231
  hair_mask_binary = (hair_mask > 0.5)
232
+ if np.sum(hair_mask_binary) > 80:
233
+ mean_hair_lab = np.mean(hair_lab[hair_mask_binary], axis=0)
234
+ std_hair_lab = np.std(hair_lab[hair_mask_binary], axis=0)
 
235
  else:
 
236
  mean_hair_lab = np.array([128, 0, 0])
237
+ std_hair_lab = np.array([30, 12, 12])
238
+
 
239
  beard_mask_binary = (beard_mask > 0.5)
240
  if np.sum(beard_mask_binary) > 0:
241
  beard_pixels_lab = img_lab[beard_mask_binary]
242
+ mean_beard = np.mean(beard_pixels_lab, axis=0)
243
+ std_beard = np.maximum(np.std(beard_pixels_lab, axis=0), 1e-5)
244
 
245
+ beard_norm = (beard_pixels_lab - mean_beard) / std_beard
 
 
 
246
  beard_transfer = beard_norm * std_hair_lab + mean_hair_lab
247
  beard_transfer = np.clip(beard_transfer, 0, 255)
248
+
 
249
  img_lab_transfer = img_lab.copy()
250
  img_lab_transfer[beard_mask_binary] = beard_transfer
251
  else:
252
  img_lab_transfer = img_lab
253
+
 
254
  final = cv2.cvtColor(img_lab_transfer.astype(np.uint8), cv2.COLOR_LAB2RGB).astype(np.float32) / 255.0
255
+
256
+ # Apply hair grey
257
  hair_mask_3ch = np.stack([hair_mask, hair_mask, hair_mask], axis=2)
258
  final = hair_grey * hair_mask_3ch + final * (1 - hair_mask_3ch)
259
+
260
+ # Edge blend
261
  comb_3ch = np.stack([comb, comb, comb], axis=2)
262
  final = final * comb_3ch + img * (1 - comb_3ch)
263
+
264
+ # Slight warm tint
265
  warm = np.array([5, 3, 0], dtype=np.float32) / 255.0
266
+ final = final + (warm * comb[..., None] * 0.18)
267
  final = np.clip(final * 255, 0, 255).astype(np.uint8)
268
+
269
  result = Image.fromarray(final)
270
+ result = result.filter(ImageFilter.UnsharpMask(radius=0.6, percent=55, threshold=0))
271
  return result
272
 
273
+
274
  def process_face_whitening(input_image: Image.Image) -> Image.Image:
 
275
  try:
276
  logger.info(f"→ Processing image: {input_image.size}")
277
  orig = input_image.convert("RGB")
278
  ow, oh = orig.size
279
+
280
  target_size = min(SAFE_IMG_SIZE, max(ow, oh))
281
  if target_size % 2 == 1:
282
  target_size -= 1
283
+
284
  img_resized = orig.resize((target_size, target_size), Image.LANCZOS)
285
+
286
+ logger.info("Generating hair & beard masks...")
287
  hair_mask, beard_mask = get_masks_sequential(img_resized)
288
+
289
  logger.info(f"Hair mask sum: {np.sum(hair_mask):.0f}, Beard mask sum: {np.sum(beard_mask):.0f}")
290
+
291
+ logger.info("Applying STRONG GREY HAIR...")
292
  final_img = apply_strong_grey_hair(img_resized, hair_mask, beard_mask)
293
+
294
  gc.collect()
295
  logger.info("✅ Processing completed!")
296
  return final_img.resize((ow, oh), Image.LANCZOS)
297
+
298
  except Exception as e:
299
  logger.error(f"❌ Processing error: {e}")
300
  logger.error(traceback.format_exc())
301
  raise HTTPException(status_code=500, detail=f"Processing failed: {str(e)}")
302
 
303
+
304
  # ================================================
305
  # 7. FASTAPI
306
  # ================================================
307
  app = FastAPI(title="Strong Grey Hair API")
308
+
309
+ app.add_middleware(CORSMiddleware,
310
+ allow_origins=["*"],
311
+ allow_credentials=True,
312
+ allow_methods=["*"],
313
  allow_headers=["*"])
314
 
315
  @app.on_event("startup")
 
324
  async def age_face(file: UploadFile = File(...)):
325
  if not file.content_type.startswith("image/"):
326
  raise HTTPException(400, "Only image files allowed")
327
+
328
  contents = await file.read()
329
  try:
330
  input_image = Image.open(io.BytesIO(contents)).convert("RGB")
331
  loop = asyncio.get_event_loop()
332
  result = await loop.run_in_executor(executor, process_face_whitening, input_image)
333
+
334
  buf = io.BytesIO()
335
  result.save(buf, format="JPEG", quality=92, optimize=True)
336
  buf.seek(0)
 
341
  finally:
342
  gc.collect()
343
 
344
+
345
  if __name__ == "__main__":
346
  import uvicorn
347
  logger.info("🚀 Starting STRONG GREY HAIR server on port 7860")
348
+ uvicorn.run(app, host="0.0.0.0", port=7860)