vitordigitizing commited on
Commit
ac0479f
·
verified ·
1 Parent(s): 8112149

Update faceless_processing.py

Browse files
Files changed (1) hide show
  1. faceless_processing.py +88 -171
faceless_processing.py CHANGED
@@ -16,10 +16,8 @@ except Exception as e:
16
  print(f"[AI] Failed to load rembg session: {e}")
17
  _SESSION_ISNET = None
18
 
19
- # Globally download cascades and models at startup if missing (runs on main container with internet access)
20
  def _download_assets_globally():
21
  try:
22
- # 1. Cascades
23
  cascade_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "cascades")
24
  os.makedirs(cascade_dir, exist_ok=True)
25
  xml_urls = {
@@ -29,13 +27,12 @@ def _download_assets_globally():
29
  for name, url in xml_urls.items():
30
  path = os.path.join(cascade_dir, name)
31
  if not os.path.exists(path) or os.path.getsize(path) < 1000:
32
- print(f"[AI] Downloading cascade {name} globally from OpenCV GitHub...")
33
  try:
34
  urllib.request.urlretrieve(url, path)
35
  except Exception as dl_err:
36
- print(f"[AI] Failed to download {name} globally: {dl_err}")
37
 
38
- # 2. MediaPipe Model
39
  model_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "models")
40
  os.makedirs(model_dir, exist_ok=True)
41
  model_path = os.path.join(model_dir, "face_landmarker.task")
@@ -44,9 +41,8 @@ def _download_assets_globally():
44
  try:
45
  model_url = "https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task"
46
  urllib.request.urlretrieve(model_url, model_path)
47
- print("[AI] face_landmarker.task download complete!")
48
  except Exception as dl_err:
49
- print(f"[AI] Failed to download face_landmarker.task globally: {dl_err}")
50
  except Exception as e:
51
  print(f"[AI] Global asset download manager failed: {e}")
52
 
@@ -55,7 +51,6 @@ _download_assets_globally()
55
 
56
  @dataclass
57
  class LineartConfig:
58
- # Backward compatibility fields (unused by OpenCV pipeline but required by callers)
59
  prompt: str = ""
60
  negative_prompt: str = ""
61
  stable_diffusion_model_id: str = ""
@@ -68,46 +63,44 @@ class LineartConfig:
68
  transparency_threshold: float = 0.03
69
  detect_eyebrows: bool = True
70
 
71
-
72
  # Subject mask
73
- white_bg_threshold: int = 245 # pixels above this = background (raised to avoid erasing light subjects)
74
- mask_close_iterations: int = 4
75
  mask_open_iterations: int = 1
76
- mask_smooth_ksize: int = 5 # GaussianBlur kernel before silhouette (must be odd; 0 = disabled)
77
- mask_smooth_threshold: int = 128 # re-binarize after blur
78
- min_silhouette_area: float = 200.0 # drop tiny isolated blobs from the silhouette (px²)
79
 
80
  # Silhouette outline
81
- silhouette_line_thickness: int = 3 # outer contour stroke width (px); match interior for uniform look
82
-
83
- # Edge detection
84
- canny_low: int = 40
85
- canny_high: int = 110
86
- bilateral_d: int = 11
87
- bilateral_sigma_color: int = 80
88
- bilateral_sigma_space: int = 80
89
- gaussian_ksize: int = 9
90
- min_contour_perimeter: float = 50.0 # filters out short noisy lines
91
- interior_line_thickness: int = 3 # thickness of interior detail lines (>=1)
92
 
93
  # Face detection
94
- face_scale_factor: float = 1.05
95
  face_min_neighbors: int = 3
96
- face_min_size: Tuple[int, int] = (25, 25)
97
- face_max_y_ratio: float = 0.6 # ignore faces below 60% image height
98
- face_nms_iou_thresh: float = 0.4
99
 
100
- # Face erase ellipse (ratios relative to face bbox) — smaller = more conservative
101
  face_cx_ratio: float = 0.5
102
- face_cy_ratio: float = 0.45
103
- face_rx_ratio: float = 0.35 # reduced from 0.40 to avoid erasing neck/ears
104
- face_ry_ratio: float = 0.38 # reduced from 0.42 to avoid erasing chin/neck
105
- face_erase_blur: int = 8
106
- face_erase_threshold: int = 130 # higher = sharper face mask edge, less spill
107
 
108
 
109
  def _nms_faces(faces: list, iou_thresh: float) -> list:
110
- """Non-maximum suppression to remove duplicate face detections."""
111
  if not faces:
112
  return []
113
  boxes = np.array(faces, dtype=float)
@@ -130,13 +123,11 @@ def _nms_faces(faces: list, iou_thresh: float) -> list:
130
 
131
 
132
  def detect_faces(rgb: np.ndarray, cfg: LineartConfig) -> list:
133
- """Detect faces and filter out false positives in lower body."""
134
  h = rgb.shape[0]
135
  gray = cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY)
136
  faces = []
137
 
138
  try:
139
- # Load local classifiers from downloaded folder
140
  cascade_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "cascades")
141
  path_default = os.path.join(cascade_dir, 'haarcascade_frontalface_default.xml')
142
  path_alt = os.path.join(cascade_dir, 'haarcascade_frontalface_alt2.xml')
@@ -147,7 +138,6 @@ def detect_faces(rgb: np.ndarray, cfg: LineartConfig) -> list:
147
  if os.path.exists(path_alt):
148
  classifiers.append(cv2.CascadeClassifier(path_alt))
149
 
150
- # Fallback to cv2 data directory if local files are missing
151
  if not classifiers or all(c.empty() for c in classifiers):
152
  classifiers = [
153
  cv2.CascadeClassifier(cv2.data.haarcascades + 'haarcascade_frontalface_default.xml'),
@@ -165,38 +155,28 @@ def detect_faces(rgb: np.ndarray, cfg: LineartConfig) -> list:
165
  if len(detected) > 0:
166
  faces.extend(detected.tolist())
167
  except Exception as e:
168
- print(f"[AI] Face detection failed or cascades unavailable: {e}")
169
 
170
- # Remove detections in lower body (false positives)
171
  faces = [f for f in faces if f[1] < h * cfg.face_max_y_ratio]
172
-
173
- # Remove duplicates
174
- faces = _nms_faces(faces, cfg.face_nms_iou_thresh)
175
- return faces
176
 
177
 
178
  def build_subject_mask(rgb: np.ndarray, cfg: LineartConfig, alpha_channel: Optional[np.ndarray] = None) -> np.ndarray:
179
- """Build subject mask. Uses alpha channel if available, otherwise thresholds white background."""
180
  if alpha_channel is not None:
181
- # Use rembg/RGBA alpha directly — much more accurate than color threshold
182
  mask = (alpha_channel > 30).astype(np.uint8) * 255
183
  else:
184
  white_mask = np.all(rgb > cfg.white_bg_threshold, axis=2)
185
  mask = (~white_mask).astype(np.uint8) * 255
186
- kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (7, 7))
187
  mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel, iterations=cfg.mask_close_iterations)
188
  mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel, iterations=cfg.mask_open_iterations)
189
  return mask
190
 
191
 
192
  def build_silhouette(subject_mask: np.ndarray, cfg: LineartConfig) -> np.ndarray:
193
- """Draw the subject silhouette as a smooth, solid-stroke contour."""
194
  mask = subject_mask.copy()
195
-
196
- # Smooth the mask boundary to eliminate staircase jaggies before tracing
197
  if cfg.mask_smooth_ksize > 1:
198
- # Use a larger kernel (at least 15) for beautiful organic curves
199
- k = max(15, cfg.mask_smooth_ksize if cfg.mask_smooth_ksize % 2 == 1 else cfg.mask_smooth_ksize + 1)
200
  mask = cv2.GaussianBlur(mask, (k, k), 0)
201
  _, mask = cv2.threshold(mask, cfg.mask_smooth_threshold, 255, cv2.THRESH_BINARY)
202
 
@@ -204,15 +184,13 @@ def build_silhouette(subject_mask: np.ndarray, cfg: LineartConfig) -> np.ndarray
204
  silhouette = np.zeros_like(subject_mask)
205
  for cnt in contours:
206
  if cv2.contourArea(cnt) >= cfg.min_silhouette_area:
207
- # Smooth contour slightly without polygonal blockiness
208
- eps = 0.0012 * cv2.arcLength(cnt, True)
209
  smooth = cv2.approxPolyDP(cnt, eps, True)
210
  cv2.drawContours(silhouette, [smooth], -1, 255, cfg.silhouette_line_thickness)
211
  return silhouette
212
 
213
 
214
  def build_face_erase_mask(faces: list, shape: Tuple[int, int], cfg: LineartConfig) -> np.ndarray:
215
- """Build a soft binary mask covering face interior regions."""
216
  h, w = shape
217
  mask = np.zeros((h, w), dtype=np.uint8)
218
  for (fx, fy, fw, fh) in faces:
@@ -221,39 +199,39 @@ def build_face_erase_mask(faces: list, shape: Tuple[int, int], cfg: LineartConfi
221
  rx = int(fw * cfg.face_rx_ratio)
222
  ry = int(fh * cfg.face_ry_ratio)
223
  cv2.ellipse(mask, (cx, cy), (rx, ry), 0, 0, 360, 255, -1)
224
- soft = np.array(Image.fromarray(mask).filter(ImageFilter.GaussianBlur(cfg.face_erase_blur)))
225
- return (soft > cfg.face_erase_threshold).astype(np.uint8) * 255
 
 
 
226
 
227
 
228
  def build_interior_edges(rgb: np.ndarray, subject_mask: np.ndarray, cfg: LineartConfig) -> np.ndarray:
229
- """Detect structural body lines using Canny, filtering noise."""
230
  gray = cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY)
231
- blurred = cv2.GaussianBlur(gray, (cfg.gaussian_ksize, cfg.gaussian_ksize), 0)
232
- blurred = cv2.bilateralFilter(blurred, cfg.bilateral_d, cfg.bilateral_sigma_color, cfg.bilateral_sigma_space)
 
 
 
 
233
  edges = cv2.Canny(blurred, cfg.canny_low, cfg.canny_high)
234
 
235
- # Erode the subject mask so Canny doesn't catch the outer boundary twice
236
- erode_size = max(5, cfg.silhouette_line_thickness * 2 + 3)
237
  if erode_size % 2 == 0:
238
  erode_size += 1
239
  kernel_erode = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (erode_size, erode_size))
240
  eroded_mask = cv2.erode(subject_mask, kernel_erode, iterations=1)
241
  edges = cv2.bitwise_and(edges, eroded_mask)
242
 
243
- # Filter out short/noisy contours
244
  contours, _ = cv2.findContours(edges, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)
245
  filtered = np.zeros_like(edges)
246
  for cnt in contours:
247
  perimeter = cv2.arcLength(cnt, False)
248
  if perimeter > cfg.min_contour_perimeter:
249
- # Simplify interior edges slightly to remove Canny wobbles while keeping natural flow
250
- eps = 0.0012 * perimeter
251
  smooth = cv2.approxPolyDP(cnt, eps, False)
252
  cv2.drawContours(filtered, [smooth], -1, 255, cfg.interior_line_thickness)
253
 
254
- # Mild dilation for uniform, solid strokes
255
- kernel_dilate = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3))
256
- filtered = cv2.dilate(filtered, kernel_dilate, iterations=1)
257
  return filtered
258
 
259
 
@@ -272,133 +250,88 @@ def get_landmarker(model_path):
272
  base_options=base_options,
273
  output_face_blendshapes=False,
274
  output_facial_transformation_matrixes=False,
275
- num_faces=1
276
  )
277
  _LANDMARKER_CACHE = vision.FaceLandmarker.create_from_options(options)
278
  return _LANDMARKER_CACHE
279
 
280
 
281
  def draw_eyebrows(rgb: np.ndarray, cfg: LineartConfig, faces: Optional[list] = None) -> np.ndarray:
282
- """Detect and draw eyebrows landmarks using MediaPipe Task API with hybrid global + crop detection."""
283
  h, w, _ = rgb.shape
284
  eyebrows_layer = np.zeros((h, w), dtype=np.uint8)
285
 
286
  try:
287
  import mediapipe as mp
288
- from mediapipe.tasks import python
289
- from mediapipe.tasks.python import vision
290
-
291
- # Determine model path
292
  model_path = cfg.mediapipe_model_path
293
  if not model_path or not os.path.exists(model_path):
294
  model_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "models")
295
- os.makedirs(model_dir, exist_ok=True)
296
  model_path = os.path.join(model_dir, "face_landmarker.task")
297
- if not os.path.exists(model_path) or os.path.getsize(model_path) < 1000000:
298
- print("[AI] Downloading face_landmarker.task for eyebrows...")
299
- model_url = "https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task"
300
- urllib.request.urlretrieve(model_url, model_path)
301
- print("[AI] Download complete!")
302
 
 
303
  LEFT_EYEBROW = [70, 63, 105, 66, 107, 55, 65, 52, 53, 46]
304
  RIGHT_EYEBROW = [336, 296, 334, 293, 300, 285, 295, 282, 283, 276]
305
 
306
- eyebrow_thickness = max(1, cfg.interior_line_thickness - 1)
307
- processed_centers = []
308
-
309
- def is_duplicate(cx, cy):
310
- for (pcx, pcy) in processed_centers:
311
- dist = np.sqrt((cx - pcx)**2 + (cy - pcy)**2)
312
- if dist < 0.08:
313
- return True
314
- return False
315
-
316
- if faces is None:
317
- faces = detect_faces(rgb, cfg)
318
 
319
  with _LANDMARKER_LOCK:
320
  landmarker = get_landmarker(model_path)
321
 
322
- # Phase 1: Global detection (upright / clear faces)
323
  mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=rgb)
324
  global_result = landmarker.detect(mp_image)
 
325
  if global_result.face_landmarks:
326
  for face_landmarks in global_result.face_landmarks:
327
- xs = [lm.x for lm in face_landmarks]
328
- ys = [lm.y for lm in face_landmarks]
329
- cx, cy = np.mean(xs), np.mean(ys)
330
- if not is_duplicate(cx, cy):
331
- processed_centers.append((cx, cy))
332
- for indices in [LEFT_EYEBROW, RIGHT_EYEBROW]:
333
- points = []
334
- for idx in indices:
335
- lm = face_landmarks[idx]
336
- points.append([int(lm.x * w), int(lm.y * h)])
337
- pts = np.array(points, np.int32).reshape((-1, 1, 2))
338
- cv2.polylines(eyebrows_layer, [pts], isClosed=False, color=255, thickness=eyebrow_thickness)
339
-
340
- # Phase 2: OpenCV crops with 45% margin (for tilted / overlapping faces)
341
- for i, (fx, fy, fw, fh) in enumerate(faces):
342
- margin_x = int(fw * 0.45)
343
- margin_y = int(fh * 0.45)
344
- x1 = max(0, fx - margin_x)
345
- y1 = max(0, fy - margin_y)
346
- x2 = min(w, fx + fw + margin_x)
347
- y2 = min(h, fy + fh + margin_y)
348
-
349
- crop_w = x2 - x1
350
- crop_h = y2 - y1
351
- if crop_w <= 0 or crop_h <= 0:
352
- continue
353
-
354
- crop_cx = (x1 + crop_w / 2) / w
355
- crop_cy = (y1 + crop_h / 2) / h
356
- if is_duplicate(crop_cx, crop_cy):
357
- continue
358
 
359
  crop = rgb[y1:y2, x1:x2]
360
- crop_resized = cv2.resize(crop, (512, 512), interpolation=cv2.INTER_CUBIC)
 
 
361
  crop_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=crop_resized)
362
  crop_result = landmarker.detect(crop_image)
363
 
364
  if crop_result.face_landmarks:
365
  face_landmarks = crop_result.face_landmarks[0]
366
- xs_global = [(x1 + lm.x * crop_w) / w for lm in face_landmarks]
367
- ys_global = [(y1 + lm.y * crop_h) / h for lm in face_landmarks]
368
- cx, cy = np.mean(xs_global), np.mean(ys_global)
369
- if not is_duplicate(cx, cy):
370
- processed_centers.append((cx, cy))
371
- for indices in [LEFT_EYEBROW, RIGHT_EYEBROW]:
372
- points = []
373
- for idx in indices:
374
- lm = face_landmarks[idx]
375
- gx = int(x1 + lm.x * crop_w)
376
- gy = int(y1 + lm.y * crop_h)
377
- points.append([gx, gy])
378
- pts = np.array(points, np.int32).reshape((-1, 1, 2))
379
- cv2.polylines(eyebrows_layer, [pts], isClosed=False, color=255, thickness=eyebrow_thickness)
380
  except Exception as e:
381
- print(f"[AI] Eyebrow extraction error or library missing: {e}")
382
 
383
  return eyebrows_layer
384
 
385
 
386
-
387
- def make_faceless_lineart(
388
- img: Image.Image,
389
- cfg: LineartConfig = None
390
- ) -> Image.Image:
391
- """
392
- Generate faceless lineart transparent image from a photo.
393
- """
394
  cfg = cfg or LineartConfig()
395
 
396
- # Preserve alpha channel if image already has a transparent background (e.g. from rembg)
397
  alpha_channel: Optional[np.ndarray] = None
398
  if img.mode in ("RGBA", "LA") or (img.mode == "P" and "transparency" in img.info):
399
  rgba_img = img.convert("RGBA")
400
  alpha_np = np.array(rgba_img)[:, :, 3]
401
- # Only use alpha mask if it contains meaningful transparency
402
  if np.any(alpha_np < 200):
403
  alpha_channel = alpha_np
404
  background = Image.new("RGBA", img.size, (255, 255, 255, 255))
@@ -411,45 +344,31 @@ def make_faceless_lineart(
411
  rgb = img_np[:, :, :3]
412
  h, w = rgb.shape[:2]
413
 
414
- # 1. Subject mask — use alpha if available, else color threshold
415
  subject_mask = build_subject_mask(rgb, cfg, alpha_channel)
416
-
417
- # 2. Detect faces
418
  faces = detect_faces(rgb, cfg)
419
- print(f"[lineart] Detected {len(faces)} face(s)")
420
 
421
- # 3. Silhouette outline
422
  silhouette = build_silhouette(subject_mask, cfg)
423
-
424
- # 4. Interior structural edges
425
  interior = build_interior_edges(rgb, subject_mask, cfg)
426
 
427
- # 5. Erase face details (keep silhouette outline of head shape)
428
  face_erase = build_face_erase_mask(faces, (h, w), cfg)
429
  interior_clean = cv2.bitwise_and(interior, cv2.bitwise_not(face_erase))
430
 
431
- # 6. Combine
432
  final_lines = cv2.add(silhouette, interior_clean)
433
 
434
- # 6.5. Optional eyebrow detection overlay
435
  if cfg.detect_eyebrows:
436
  eyebrows = draw_eyebrows(rgb, cfg, faces=faces)
437
  final_lines = cv2.add(final_lines, eyebrows)
438
 
439
- # Avoid drawing rectangular boxes/borders at the image edges
440
- border_erase_width = max(cfg.silhouette_line_thickness, cfg.interior_line_thickness) + 2
441
  if border_erase_width > 0:
442
  final_lines[0:border_erase_width, :] = 0
443
  final_lines[-border_erase_width:, :] = 0
444
  final_lines[:, 0:border_erase_width] = 0
445
  final_lines[:, -border_erase_width:] = 0
446
 
447
-
448
- # 7. Build output image
449
  alpha = Image.fromarray(final_lines)
450
- size = img.size
451
-
452
- transparent = Image.new("RGBA", size, (0, 0, 0, 0))
453
  transparent.putalpha(alpha)
454
 
455
  return transparent
@@ -470,8 +389,7 @@ def _has_real_transparency(img: Image.Image) -> bool:
470
  rgba = img.convert("RGBA")
471
  alpha = rgba.getchannel("A")
472
  sample = list(alpha.getdata())
473
- total = 0
474
- visible = 0
475
  for index in range(0, len(sample), 12):
476
  total += 1
477
  if sample[index] < 245:
@@ -484,7 +402,6 @@ def remove_background_subject(img: Image.Image) -> Image.Image:
484
  rgba = img.convert("RGBA")
485
  if _has_real_transparency(rgba):
486
  return rgba
487
-
488
  if _SESSION_ISNET is None:
489
  return rgba
490
 
 
16
  print(f"[AI] Failed to load rembg session: {e}")
17
  _SESSION_ISNET = None
18
 
 
19
  def _download_assets_globally():
20
  try:
 
21
  cascade_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "cascades")
22
  os.makedirs(cascade_dir, exist_ok=True)
23
  xml_urls = {
 
27
  for name, url in xml_urls.items():
28
  path = os.path.join(cascade_dir, name)
29
  if not os.path.exists(path) or os.path.getsize(path) < 1000:
30
+ print(f"[AI] Downloading cascade {name} globally...")
31
  try:
32
  urllib.request.urlretrieve(url, path)
33
  except Exception as dl_err:
34
+ print(f"[AI] Failed to download {name}: {dl_err}")
35
 
 
36
  model_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "models")
37
  os.makedirs(model_dir, exist_ok=True)
38
  model_path = os.path.join(model_dir, "face_landmarker.task")
 
41
  try:
42
  model_url = "https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task"
43
  urllib.request.urlretrieve(model_url, model_path)
 
44
  except Exception as dl_err:
45
+ print(f"[AI] Failed to download face_landmarker.task: {dl_err}")
46
  except Exception as e:
47
  print(f"[AI] Global asset download manager failed: {e}")
48
 
 
51
 
52
  @dataclass
53
  class LineartConfig:
 
54
  prompt: str = ""
55
  negative_prompt: str = ""
56
  stable_diffusion_model_id: str = ""
 
63
  transparency_threshold: float = 0.03
64
  detect_eyebrows: bool = True
65
 
 
66
  # Subject mask
67
+ white_bg_threshold: int = 245
68
+ mask_close_iterations: int = 3
69
  mask_open_iterations: int = 1
70
+ mask_smooth_ksize: int = 5
71
+ mask_smooth_threshold: int = 128
72
+ min_silhouette_area: float = 100.0
73
 
74
  # Silhouette outline
75
+ silhouette_line_thickness: int = 3
76
+
77
+ # Edge detection (Ajustado para capturar mais detalhes)
78
+ canny_low: int = 30
79
+ canny_high: int = 85
80
+ bilateral_d: int = 7
81
+ bilateral_sigma_color: int = 50
82
+ bilateral_sigma_space: int = 50
83
+ gaussian_ksize: int = 3 # Reduzido de 9 para 3 para preservar nitidez
84
+ min_contour_perimeter: float = 15.0 # Reduzido de 50 para 15 para não apagar traços finos
85
+ interior_line_thickness: int = 2 # Reduzido para um traço mais sofisticado e detalhado
86
 
87
  # Face detection
88
+ face_scale_factor: float = 1.10
89
  face_min_neighbors: int = 3
90
+ face_min_size: Tuple[int, int] = (30, 30)
91
+ face_max_y_ratio: float = 0.65
92
+ face_nms_iou_thresh: float = 0.3
93
 
94
+ # Face erase ellipse (Ajustado para preservar o contorno da mandíbula/sombrancelhas nas bordas)
95
  face_cx_ratio: float = 0.5
96
+ face_cy_ratio: float = 0.5
97
+ face_rx_ratio: float = 0.32
98
+ face_ry_ratio: float = 0.35
99
+ face_erase_blur: int = 5
100
+ face_erase_threshold: int = 140
101
 
102
 
103
  def _nms_faces(faces: list, iou_thresh: float) -> list:
 
104
  if not faces:
105
  return []
106
  boxes = np.array(faces, dtype=float)
 
123
 
124
 
125
  def detect_faces(rgb: np.ndarray, cfg: LineartConfig) -> list:
 
126
  h = rgb.shape[0]
127
  gray = cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY)
128
  faces = []
129
 
130
  try:
 
131
  cascade_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "cascades")
132
  path_default = os.path.join(cascade_dir, 'haarcascade_frontalface_default.xml')
133
  path_alt = os.path.join(cascade_dir, 'haarcascade_frontalface_alt2.xml')
 
138
  if os.path.exists(path_alt):
139
  classifiers.append(cv2.CascadeClassifier(path_alt))
140
 
 
141
  if not classifiers or all(c.empty() for c in classifiers):
142
  classifiers = [
143
  cv2.CascadeClassifier(cv2.data.haarcascades + 'haarcascade_frontalface_default.xml'),
 
155
  if len(detected) > 0:
156
  faces.extend(detected.tolist())
157
  except Exception as e:
158
+ print(f"[AI] Face detection error: {e}")
159
 
 
160
  faces = [f for f in faces if f[1] < h * cfg.face_max_y_ratio]
161
+ return _nms_faces(faces, cfg.face_nms_iou_thresh)
 
 
 
162
 
163
 
164
  def build_subject_mask(rgb: np.ndarray, cfg: LineartConfig, alpha_channel: Optional[np.ndarray] = None) -> np.ndarray:
 
165
  if alpha_channel is not None:
 
166
  mask = (alpha_channel > 30).astype(np.uint8) * 255
167
  else:
168
  white_mask = np.all(rgb > cfg.white_bg_threshold, axis=2)
169
  mask = (~white_mask).astype(np.uint8) * 255
170
+ kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
171
  mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel, iterations=cfg.mask_close_iterations)
172
  mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel, iterations=cfg.mask_open_iterations)
173
  return mask
174
 
175
 
176
  def build_silhouette(subject_mask: np.ndarray, cfg: LineartConfig) -> np.ndarray:
 
177
  mask = subject_mask.copy()
 
 
178
  if cfg.mask_smooth_ksize > 1:
179
+ k = max(7, cfg.mask_smooth_ksize if cfg.mask_smooth_ksize % 2 == 1 else cfg.mask_smooth_ksize + 1)
 
180
  mask = cv2.GaussianBlur(mask, (k, k), 0)
181
  _, mask = cv2.threshold(mask, cfg.mask_smooth_threshold, 255, cv2.THRESH_BINARY)
182
 
 
184
  silhouette = np.zeros_like(subject_mask)
185
  for cnt in contours:
186
  if cv2.contourArea(cnt) >= cfg.min_silhouette_area:
187
+ eps = 0.0008 * cv2.arcLength(cnt, True)
 
188
  smooth = cv2.approxPolyDP(cnt, eps, True)
189
  cv2.drawContours(silhouette, [smooth], -1, 255, cfg.silhouette_line_thickness)
190
  return silhouette
191
 
192
 
193
  def build_face_erase_mask(faces: list, shape: Tuple[int, int], cfg: LineartConfig) -> np.ndarray:
 
194
  h, w = shape
195
  mask = np.zeros((h, w), dtype=np.uint8)
196
  for (fx, fy, fw, fh) in faces:
 
199
  rx = int(fw * cfg.face_rx_ratio)
200
  ry = int(fh * cfg.face_ry_ratio)
201
  cv2.ellipse(mask, (cx, cy), (rx, ry), 0, 0, 360, 255, -1)
202
+
203
+ if len(faces) > 0:
204
+ soft = np.array(Image.fromarray(mask).filter(ImageFilter.GaussianBlur(cfg.face_erase_blur)))
205
+ return (soft > cfg.face_erase_threshold).astype(np.uint8) * 255
206
+ return mask
207
 
208
 
209
  def build_interior_edges(rgb: np.ndarray, subject_mask: np.ndarray, cfg: LineartConfig) -> np.ndarray:
 
210
  gray = cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY)
211
+
212
+ # Reduzido o pré-processamento destrutivo de desfoque
213
+ if cfg.gaussian_ksize > 0:
214
+ gray = cv2.GaussianBlur(gray, (cfg.gaussian_ksize, cfg.gaussian_ksize), 0)
215
+ blurred = cv2.bilateralFilter(gray, cfg.bilateral_d, cfg.bilateral_sigma_color, cfg.bilateral_sigma_space)
216
+
217
  edges = cv2.Canny(blurred, cfg.canny_low, cfg.canny_high)
218
 
219
+ erode_size = max(3, cfg.silhouette_line_thickness + 1)
 
220
  if erode_size % 2 == 0:
221
  erode_size += 1
222
  kernel_erode = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (erode_size, erode_size))
223
  eroded_mask = cv2.erode(subject_mask, kernel_erode, iterations=1)
224
  edges = cv2.bitwise_and(edges, eroded_mask)
225
 
 
226
  contours, _ = cv2.findContours(edges, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)
227
  filtered = np.zeros_like(edges)
228
  for cnt in contours:
229
  perimeter = cv2.arcLength(cnt, False)
230
  if perimeter > cfg.min_contour_perimeter:
231
+ eps = 0.0005 * perimeter # Simplificação reduzida para manter curvas orgânicas
 
232
  smooth = cv2.approxPolyDP(cnt, eps, False)
233
  cv2.drawContours(filtered, [smooth], -1, 255, cfg.interior_line_thickness)
234
 
 
 
 
235
  return filtered
236
 
237
 
 
250
  base_options=base_options,
251
  output_face_blendshapes=False,
252
  output_facial_transformation_matrixes=False,
253
+ num_faces=4 # Aumentado para suportar mais rostos caso necessário
254
  )
255
  _LANDMARKER_CACHE = vision.FaceLandmarker.create_from_options(options)
256
  return _LANDMARKER_CACHE
257
 
258
 
259
  def draw_eyebrows(rgb: np.ndarray, cfg: LineartConfig, faces: Optional[list] = None) -> np.ndarray:
 
260
  h, w, _ = rgb.shape
261
  eyebrows_layer = np.zeros((h, w), dtype=np.uint8)
262
 
263
  try:
264
  import mediapipe as mp
 
 
 
 
265
  model_path = cfg.mediapipe_model_path
266
  if not model_path or not os.path.exists(model_path):
267
  model_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "models")
 
268
  model_path = os.path.join(model_dir, "face_landmarker.task")
 
 
 
 
 
269
 
270
+ # Índices corrigidos e completos das sobrancelhas no MediaPipe Mesh
271
  LEFT_EYEBROW = [70, 63, 105, 66, 107, 55, 65, 52, 53, 46]
272
  RIGHT_EYEBROW = [336, 296, 334, 293, 300, 285, 295, 282, 283, 276]
273
 
274
+ eyebrow_thickness = cfg.interior_line_thickness
 
 
 
 
 
 
 
 
 
 
 
275
 
276
  with _LANDMARKER_LOCK:
277
  landmarker = get_landmarker(model_path)
278
 
279
+ # Executa sempre o modelo global primeiro (MediaPipe lida bem com rostos inteiros)
280
  mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=rgb)
281
  global_result = landmarker.detect(mp_image)
282
+
283
  if global_result.face_landmarks:
284
  for face_landmarks in global_result.face_landmarks:
285
+ for indices in [LEFT_EYEBROW, RIGHT_EYEBROW]:
286
+ points = []
287
+ for idx in indices:
288
+ lm = face_landmarks[idx]
289
+ points.append([int(lm.x * w), int(lm.y * h)])
290
+ pts = np.array(points, np.int32).reshape((-1, 1, 2))
291
+ cv2.polylines(eyebrows_layer, [pts], isClosed=False, color=255, thickness=eyebrow_thickness)
292
+ return eyebrows_layer # Se o global resolveu perfeitamente, retorna direto
293
+
294
+ # Se o global falhou, tenta via crops gerados pelo Haar Cascades
295
+ if faces is None:
296
+ faces = detect_faces(rgb, cfg)
297
+
298
+ for (fx, fy, fw, fh) in faces:
299
+ margin_x = int(fw * 0.3)
300
+ margin_y = int(fh * 0.3)
301
+ x1, y1 = max(0, fx - margin_x), max(0, fy - margin_y)
302
+ x2, y2 = min(w, fx + fw + margin_x), min(h, fy + fh + margin_y)
 
 
 
 
 
 
 
 
 
 
 
 
 
303
 
304
  crop = rgb[y1:y2, x1:x2]
305
+ if crop.size == 0:
306
+ continue
307
+ crop_resized = cv2.resize(crop, (256, 256), interpolation=cv2.INTER_LINEAR)
308
  crop_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=crop_resized)
309
  crop_result = landmarker.detect(crop_image)
310
 
311
  if crop_result.face_landmarks:
312
  face_landmarks = crop_result.face_landmarks[0]
313
+ for indices in [LEFT_EYEBROW, RIGHT_EYEBROW]:
314
+ points = []
315
+ for idx in indices:
316
+ lm = face_landmarks[idx]
317
+ gx = int(x1 + (lm.x * (x2 - x1)))
318
+ gy = int(y1 + (lm.y * (y2 - y1)))
319
+ points.append([gx, gy])
320
+ pts = np.array(points, np.int32).reshape((-1, 1, 2))
321
+ cv2.polylines(eyebrows_layer, [pts], isClosed=False, color=255, thickness=eyebrow_thickness)
 
 
 
 
 
322
  except Exception as e:
323
+ print(f"[AI] Eyebrow extraction error: {e}")
324
 
325
  return eyebrows_layer
326
 
327
 
328
+ def make_faceless_lineart(img: Image.Image, cfg: LineartConfig = None) -> Image.Image:
 
 
 
 
 
 
 
329
  cfg = cfg or LineartConfig()
330
 
 
331
  alpha_channel: Optional[np.ndarray] = None
332
  if img.mode in ("RGBA", "LA") or (img.mode == "P" and "transparency" in img.info):
333
  rgba_img = img.convert("RGBA")
334
  alpha_np = np.array(rgba_img)[:, :, 3]
 
335
  if np.any(alpha_np < 200):
336
  alpha_channel = alpha_np
337
  background = Image.new("RGBA", img.size, (255, 255, 255, 255))
 
344
  rgb = img_np[:, :, :3]
345
  h, w = rgb.shape[:2]
346
 
 
347
  subject_mask = build_subject_mask(rgb, cfg, alpha_channel)
 
 
348
  faces = detect_faces(rgb, cfg)
 
349
 
 
350
  silhouette = build_silhouette(subject_mask, cfg)
 
 
351
  interior = build_interior_edges(rgb, subject_mask, cfg)
352
 
353
+ # O "Erase" agora limpa os olhos e a boca, mantendo as sobrancelhas e as bordas externas intactas
354
  face_erase = build_face_erase_mask(faces, (h, w), cfg)
355
  interior_clean = cv2.bitwise_and(interior, cv2.bitwise_not(face_erase))
356
 
 
357
  final_lines = cv2.add(silhouette, interior_clean)
358
 
 
359
  if cfg.detect_eyebrows:
360
  eyebrows = draw_eyebrows(rgb, cfg, faces=faces)
361
  final_lines = cv2.add(final_lines, eyebrows)
362
 
363
+ border_erase_width = max(cfg.silhouette_line_thickness, cfg.interior_line_thickness) + 1
 
364
  if border_erase_width > 0:
365
  final_lines[0:border_erase_width, :] = 0
366
  final_lines[-border_erase_width:, :] = 0
367
  final_lines[:, 0:border_erase_width] = 0
368
  final_lines[:, -border_erase_width:] = 0
369
 
 
 
370
  alpha = Image.fromarray(final_lines)
371
+ transparent = Image.new("RGBA", img.size, (0, 0, 0, 0))
 
 
372
  transparent.putalpha(alpha)
373
 
374
  return transparent
 
389
  rgba = img.convert("RGBA")
390
  alpha = rgba.getchannel("A")
391
  sample = list(alpha.getdata())
392
+ total, visible = 0, 0
 
393
  for index in range(0, len(sample), 12):
394
  total += 1
395
  if sample[index] < 245:
 
402
  rgba = img.convert("RGBA")
403
  if _has_real_transparency(rgba):
404
  return rgba
 
405
  if _SESSION_ISNET is None:
406
  return rgba
407