vitor.fran commited on
Commit
9dd50df
·
1 Parent(s): 5c3f5e7

fix: update faceless_processing with improved detection and face contour polygon erase

Browse files
Files changed (1) hide show
  1. faceless_processing.py +332 -155
faceless_processing.py CHANGED
@@ -10,23 +10,75 @@ import urllib.request
10
  from rembg import remove, new_session
11
  import threading
12
 
 
 
 
 
 
 
 
13
  try:
14
  _SESSION_ISNET = new_session("isnet-general-use")
15
  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 = {
26
- 'haarcascade_frontalface_default.xml': 'https://raw.githubusercontent.com/opencv/opencv/master/data/haarcascades/haarcascade_frontalface_default.xml',
27
  'haarcascade_frontalface_alt2.xml': 'https://raw.githubusercontent.com/opencv/opencv/master/data/haarcascades/haarcascade_frontalface_alt2.xml',
28
  'haarcascade_profileface.xml': 'https://raw.githubusercontent.com/opencv/opencv/master/data/haarcascades/haarcascade_profileface.xml'
29
  }
 
 
 
30
  for name, url in xml_urls.items():
31
  path = os.path.join(cascade_dir, name)
32
  if not os.path.exists(path) or os.path.getsize(path) < 1000:
@@ -36,7 +88,6 @@ def _download_assets_globally():
36
  except Exception as dl_err:
37
  print(f"[AI] Failed to download {name} globally: {dl_err}")
38
 
39
- # 2. MediaPipe Model
40
  model_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "models")
41
  os.makedirs(model_dir, exist_ok=True)
42
  model_path = os.path.join(model_dir, "face_landmarker.task")
@@ -52,11 +103,14 @@ def _download_assets_globally():
52
  print(f"[AI] Global asset download manager failed: {e}")
53
 
54
  _download_assets_globally()
 
 
 
55
 
56
 
57
  @dataclass
58
  class LineartConfig:
59
- # Backward compatibility fields (unused by OpenCV pipeline but required by callers)
60
  prompt: str = ""
61
  negative_prompt: str = ""
62
  stable_diffusion_model_id: str = ""
@@ -69,46 +123,80 @@ class LineartConfig:
69
  transparency_threshold: float = 0.03
70
  detect_eyebrows: bool = True
71
 
72
-
73
  # Subject mask
74
- white_bg_threshold: int = 245 # pixels above this = background (raised to avoid erasing light subjects)
75
  mask_close_iterations: int = 4
76
  mask_open_iterations: int = 1
77
- mask_smooth_ksize: int = 5 # GaussianBlur kernel before silhouette (must be odd; 0 = disabled)
78
- mask_smooth_threshold: int = 128 # re-binarize after blur
79
- min_silhouette_area: float = 200.0 # drop tiny isolated blobs from the silhouette (px²)
80
 
81
  # Silhouette outline
82
- silhouette_line_thickness: int = 4 # outer contour stroke width (px); match interior for uniform look
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
 
84
- # Edge detection
85
  canny_low: int = 35
86
- canny_high: int = 85
87
  bilateral_d: int = 11
88
- bilateral_sigma_color: int = 80
89
- bilateral_sigma_space: int = 80
90
- gaussian_ksize: int = 9
91
- min_contour_perimeter: float = 25.0 # filters out short noisy lines
92
- interior_line_thickness: int = 3 # thickness of interior detail lines (>=1)
 
 
 
 
 
 
 
93
 
94
  # Face detection
95
  face_scale_factor: float = 1.05
96
  face_min_neighbors: int = 3
97
- face_min_size: Tuple[int, int] = (25, 25)
98
- face_max_y_ratio: float = 0.6 # ignore faces below 60% image height
99
- face_nms_iou_thresh: float = 0.4
 
100
 
101
- # Face erase ellipse (ratios relative to face bbox) — fallback parameters
102
- face_cx_ratio: float = 0.5
103
  face_cy_ratio: float = 0.50
104
- face_rx_ratio: float = 0.44 # adjusted for better fallback erasure
105
  face_ry_ratio: float = 0.46
106
- face_erase_blur: int = 8
107
- face_erase_threshold: int = 130 # higher = sharper face mask edge, less spill
 
 
 
 
 
 
 
 
 
 
 
 
 
 
108
 
109
 
110
  def _nms_faces(faces: list, iou_thresh: float) -> list:
111
- """Non-maximum suppression to remove duplicate face detections."""
112
  if not faces:
113
  return []
114
  boxes = np.array(faces, dtype=float)
@@ -131,7 +219,6 @@ def _nms_faces(faces: list, iou_thresh: float) -> list:
131
 
132
 
133
  def detect_faces(rgb: np.ndarray, cfg: LineartConfig) -> list:
134
- """Detect faces and filter out false positives in lower body."""
135
  h, w = rgb.shape[:2]
136
  faces = []
137
 
@@ -144,7 +231,7 @@ def detect_faces(rgb: np.ndarray, cfg: LineartConfig) -> list:
144
  model_path = os.path.join(model_dir, "face_landmarker.task")
145
 
146
  if os.path.exists(model_path):
147
- num_faces = 6
148
  with _LANDMARKER_LOCK:
149
  landmarker = get_landmarker(model_path, num_faces=num_faces)
150
  mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=rgb)
@@ -214,13 +301,14 @@ def detect_faces(rgb: np.ndarray, cfg: LineartConfig) -> list:
214
  # 3. Filter and Deduplicate
215
  faces = [f for f in faces if f[1] < h * cfg.face_max_y_ratio]
216
  faces = _nms_faces(faces, cfg.face_nms_iou_thresh)
 
 
 
217
  return faces
218
 
219
 
220
  def build_subject_mask(rgb: np.ndarray, cfg: LineartConfig, alpha_channel: Optional[np.ndarray] = None) -> np.ndarray:
221
- """Build subject mask. Uses alpha channel if available, otherwise thresholds white background."""
222
  if alpha_channel is not None:
223
- # Use rembg/RGBA alpha directly — much more accurate than color threshold
224
  mask = (alpha_channel > 30).astype(np.uint8) * 255
225
  else:
226
  white_mask = np.all(rgb > cfg.white_bg_threshold, axis=2)
@@ -231,8 +319,24 @@ def build_subject_mask(rgb: np.ndarray, cfg: LineartConfig, alpha_channel: Optio
231
  return mask
232
 
233
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
234
  def build_silhouette(subject_mask: np.ndarray, cfg: LineartConfig) -> np.ndarray:
235
- """Draw the subject silhouette as a smooth, solid-stroke contour."""
236
  mask = subject_mask.copy()
237
  h, w = mask.shape[:2]
238
  # Dynamic proportional smoothing based on image size to eliminate jaggies completely
@@ -242,19 +346,29 @@ def build_silhouette(subject_mask: np.ndarray, cfg: LineartConfig) -> np.ndarray
242
  k = max(25, k)
243
  mask = cv2.GaussianBlur(mask, (k, k), 0)
244
  _, mask = cv2.threshold(mask, cfg.mask_smooth_threshold, 255, cv2.THRESH_BINARY)
 
 
 
245
 
246
- contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_TC89_KCOS)
247
  silhouette = np.zeros_like(subject_mask)
248
  for cnt in contours:
249
- if cv2.contourArea(cnt) >= cfg.min_silhouette_area:
 
 
 
 
 
 
 
250
  eps = 0.0012 * cv2.arcLength(cnt, True)
251
- smooth = cv2.approxPolyDP(cnt, eps, True)
252
- cv2.drawContours(silhouette, [smooth], -1, 255, cfg.silhouette_line_thickness, lineType=cv2.LINE_AA)
 
253
  return silhouette
254
 
255
 
256
  def build_face_erase_mask(rgb: np.ndarray, faces: list, cfg: LineartConfig) -> np.ndarray:
257
- """Build a soft binary mask covering face interior regions using MediaPipe polygon or fallback ellipse."""
258
  h, w = rgb.shape[:2]
259
  mask = np.zeros((h, w), dtype=np.uint8)
260
 
@@ -268,7 +382,7 @@ def build_face_erase_mask(rgb: np.ndarray, faces: list, cfg: LineartConfig) -> n
268
  model_path = os.path.join(model_dir, "face_landmarker.task")
269
 
270
  if os.path.exists(model_path):
271
- num_faces = 6
272
  with _LANDMARKER_LOCK:
273
  landmarker = get_landmarker(model_path, num_faces=num_faces)
274
  mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=rgb)
@@ -304,14 +418,81 @@ def build_face_erase_mask(rgb: np.ndarray, faces: list, cfg: LineartConfig) -> n
304
  return (soft > cfg.face_erase_threshold).astype(np.uint8) * 255
305
 
306
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
307
  def build_interior_edges(rgb: np.ndarray, subject_mask: np.ndarray, cfg: LineartConfig) -> np.ndarray:
308
- """Detect structural body lines using Canny, filtering noise."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
309
  gray = cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY)
310
- blurred = cv2.GaussianBlur(gray, (cfg.gaussian_ksize, cfg.gaussian_ksize), 0)
311
- blurred = cv2.bilateralFilter(blurred, cfg.bilateral_d, cfg.bilateral_sigma_color, cfg.bilateral_sigma_space)
312
  edges = cv2.Canny(blurred, cfg.canny_low, cfg.canny_high)
313
-
314
- # Erode the subject mask so Canny doesn't catch the outer boundary twice
315
  erode_size = max(5, cfg.silhouette_line_thickness * 2 + 3)
316
  if erode_size % 2 == 0:
317
  erode_size += 1
@@ -319,18 +500,15 @@ def build_interior_edges(rgb: np.ndarray, subject_mask: np.ndarray, cfg: Lineart
319
  eroded_mask = cv2.erode(subject_mask, kernel_erode, iterations=1)
320
  edges = cv2.bitwise_and(edges, eroded_mask)
321
 
322
- # Filter out short/noisy contours
323
  contours, _ = cv2.findContours(edges, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)
324
  filtered = np.zeros_like(edges)
325
  for cnt in contours:
326
  perimeter = cv2.arcLength(cnt, False)
327
- if perimeter > cfg.min_contour_perimeter:
328
- # Simplify interior edges slightly to remove Canny wobbles while keeping natural flow
329
- eps = 0.0012 * perimeter
330
- smooth = cv2.approxPolyDP(cnt, eps, False)
331
- cv2.drawContours(filtered, [smooth], -1, 255, cfg.interior_line_thickness)
332
-
333
- # Mild dilation for uniform, solid strokes
334
  kernel_dilate = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3))
335
  filtered = cv2.dilate(filtered, kernel_dilate, iterations=1)
336
  return filtered
@@ -340,13 +518,12 @@ _LANDMARKER_LOCK = threading.Lock()
340
  _LANDMARKER_CACHE = None
341
  _LANDMARKER_NUM_FACES = None
342
 
343
- def get_landmarker(model_path, num_faces: int = 1):
344
  global _LANDMARKER_CACHE, _LANDMARKER_NUM_FACES
345
  if _LANDMARKER_CACHE is None or _LANDMARKER_NUM_FACES != num_faces:
346
  import mediapipe as mp
347
  from mediapipe.tasks import python
348
  from mediapipe.tasks.python import vision
349
-
350
  base_options = python.BaseOptions(model_asset_path=model_path)
351
  options = vision.FaceLandmarkerOptions(
352
  base_options=base_options,
@@ -363,16 +540,11 @@ def get_landmarker(model_path, num_faces: int = 1):
363
 
364
 
365
  def draw_eyebrows(rgb: np.ndarray, cfg: LineartConfig, faces: Optional[list] = None) -> np.ndarray:
366
- """Detect and draw eyebrows landmarks using MediaPipe Task API with hybrid global + crop detection."""
367
  h, w, _ = rgb.shape
368
  eyebrows_layer = np.zeros((h, w), dtype=np.uint8)
369
-
370
  try:
371
  import mediapipe as mp
372
- from mediapipe.tasks import python
373
- from mediapipe.tasks.python import vision
374
 
375
- # Determine model path
376
  model_path = cfg.mediapipe_model_path
377
  if not model_path or not os.path.exists(model_path):
378
  model_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "models")
@@ -382,107 +554,124 @@ def draw_eyebrows(rgb: np.ndarray, cfg: LineartConfig, faces: Optional[list] = N
382
  print("[AI] Downloading face_landmarker.task for eyebrows...")
383
  model_url = "https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task"
384
  urllib.request.urlretrieve(model_url, model_path)
385
- print("[AI] Download complete!")
386
 
387
- LEFT_EYEBROW = [70, 63, 105, 66, 107, 55, 65, 52, 53, 46]
388
  RIGHT_EYEBROW = [336, 296, 334, 293, 300, 285, 295, 282, 283, 276]
389
-
390
- eyebrow_thickness = max(1, cfg.interior_line_thickness - 1)
391
- processed_centers = []
392
-
393
- def is_duplicate(cx, cy):
394
- for (pcx, pcy) in processed_centers:
395
- dist = np.sqrt((cx - pcx)**2 + (cy - pcy)**2)
396
- if dist < 0.08:
 
 
 
 
 
 
397
  return True
398
  return False
399
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
400
  if faces is None:
401
  faces = detect_faces(rgb, cfg)
402
 
403
  with _LANDMARKER_LOCK:
404
- landmarker = get_landmarker(model_path)
405
-
406
- # Phase 1: Global detection (upright / clear faces)
407
  mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=rgb)
408
  global_result = landmarker.detect(mp_image)
409
  if global_result.face_landmarks:
410
- for face_landmarks in global_result.face_landmarks:
411
- xs = [lm.x for lm in face_landmarks]
412
- ys = [lm.y for lm in face_landmarks]
413
- cx, cy = np.mean(xs), np.mean(ys)
414
- if not is_duplicate(cx, cy):
415
- processed_centers.append((cx, cy))
416
- for indices in [LEFT_EYEBROW, RIGHT_EYEBROW]:
417
- points = []
418
- for idx in indices:
419
- lm = face_landmarks[idx]
420
- points.append([int(lm.x * w), int(lm.y * h)])
421
- pts = np.array(points, np.int32).reshape((-1, 1, 2))
422
- cv2.polylines(eyebrows_layer, [pts], isClosed=False, color=255, thickness=eyebrow_thickness)
423
-
424
- # Phase 2: OpenCV crops with 45% margin (for tilted / overlapping faces)
425
- for i, (fx, fy, fw, fh) in enumerate(faces):
426
- margin_x = int(fw * 0.45)
427
- margin_y = int(fh * 0.45)
428
- x1 = max(0, fx - margin_x)
429
- y1 = max(0, fy - margin_y)
430
- x2 = min(w, fx + fw + margin_x)
431
- y2 = min(h, fy + fh + margin_y)
432
-
433
- crop_w = x2 - x1
434
- crop_h = y2 - y1
435
- if crop_w <= 0 or crop_h <= 0:
436
  continue
437
-
438
- crop_cx = (x1 + crop_w / 2) / w
439
- crop_cy = (y1 + crop_h / 2) / h
440
- if is_duplicate(crop_cx, crop_cy):
441
  continue
442
-
443
  crop = rgb[y1:y2, x1:x2]
444
- crop_resized = cv2.resize(crop, (512, 512), interpolation=cv2.INTER_CUBIC)
 
 
 
445
  crop_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=crop_resized)
446
  crop_result = landmarker.detect(crop_image)
447
-
448
  if crop_result.face_landmarks:
449
- face_landmarks = crop_result.face_landmarks[0]
450
- xs_global = [(x1 + lm.x * crop_w) / w for lm in face_landmarks]
451
- ys_global = [(y1 + lm.y * crop_h) / h for lm in face_landmarks]
452
- cx, cy = np.mean(xs_global), np.mean(ys_global)
453
- if not is_duplicate(cx, cy):
454
- processed_centers.append((cx, cy))
455
- for indices in [LEFT_EYEBROW, RIGHT_EYEBROW]:
456
- points = []
457
- for idx in indices:
458
- lm = face_landmarks[idx]
459
- gx = int(x1 + lm.x * crop_w)
460
- gy = int(y1 + lm.y * crop_h)
461
- points.append([gx, gy])
462
- pts = np.array(points, np.int32).reshape((-1, 1, 2))
463
- cv2.polylines(eyebrows_layer, [pts], isClosed=False, color=255, thickness=eyebrow_thickness)
464
  except Exception as e:
465
  print(f"[AI] Eyebrow extraction error or library missing: {e}")
466
 
 
 
 
 
 
467
  return eyebrows_layer
468
 
469
 
470
-
471
- def make_faceless_lineart(
472
- img: Image.Image,
473
- cfg: LineartConfig = None
474
- ) -> Image.Image:
475
- """
476
- Generate faceless lineart transparent image from a photo.
477
- """
478
  cfg = cfg or LineartConfig()
479
-
480
- # Preserve alpha channel if image already has a transparent background (e.g. from rembg)
481
  alpha_channel: Optional[np.ndarray] = None
482
  if img.mode in ("RGBA", "LA") or (img.mode == "P" and "transparency" in img.info):
483
  rgba_img = img.convert("RGBA")
484
  alpha_np = np.array(rgba_img)[:, :, 3]
485
- # Only use alpha mask if it contains meaningful transparency
486
  if np.any(alpha_np < 200):
487
  alpha_channel = alpha_np
488
  background = Image.new("RGBA", img.size, (255, 255, 255, 255))
@@ -495,32 +684,32 @@ def make_faceless_lineart(
495
  rgb = img_np[:, :, :3]
496
  h, w = rgb.shape[:2]
497
 
498
- # 1. Subject mask — use alpha if available, else color threshold
499
- subject_mask = build_subject_mask(rgb, cfg, alpha_channel)
 
 
 
 
 
 
 
500
 
501
- # 2. Detect faces
502
  faces = detect_faces(rgb, cfg)
503
  print(f"[lineart] Detected {len(faces)} face(s)")
504
 
505
- # 3. Silhouette outline
506
  silhouette = build_silhouette(subject_mask, cfg)
507
-
508
- # 4. Interior structural edges
509
  interior = build_interior_edges(rgb, subject_mask, cfg)
510
 
511
- # 5. Erase face details (keep silhouette outline of head shape)
512
  face_erase = build_face_erase_mask(rgb, faces, cfg)
513
  interior_clean = cv2.bitwise_and(interior, cv2.bitwise_not(face_erase))
514
 
515
- # 6. Combine
516
  final_lines = cv2.add(silhouette, interior_clean)
517
 
518
- # 6.5. Optional eyebrow detection overlay
519
  if cfg.detect_eyebrows:
520
  eyebrows = draw_eyebrows(rgb, cfg, faces=faces)
521
  final_lines = cv2.add(final_lines, eyebrows)
522
 
523
- # Avoid drawing rectangular boxes/borders at the image edges
524
  border_erase_width = max(cfg.silhouette_line_thickness, cfg.interior_line_thickness) + 2
525
  if border_erase_width > 0:
526
  final_lines[0:border_erase_width, :] = 0
@@ -528,14 +717,9 @@ def make_faceless_lineart(
528
  final_lines[:, 0:border_erase_width] = 0
529
  final_lines[:, -border_erase_width:] = 0
530
 
531
-
532
- # 7. Build output image
533
  alpha = Image.fromarray(final_lines)
534
- size = img.size
535
-
536
- transparent = Image.new("RGBA", size, (0, 0, 0, 0))
537
  transparent.putalpha(alpha)
538
-
539
  return transparent
540
 
541
 
@@ -568,16 +752,13 @@ def remove_background_subject(img: Image.Image) -> Image.Image:
568
  rgba = img.convert("RGBA")
569
  if _has_real_transparency(rgba):
570
  return rgba
571
-
572
  if _SESSION_ISNET is None:
573
  return rgba
574
-
575
  mask = remove(rgba, session=_SESSION_ISNET, only_mask=True)
576
  alpha = mask.convert("L") if isinstance(mask, Image.Image) else Image.open(io.BytesIO(mask)).convert("L")
577
  if alpha.size != rgba.size:
578
  alpha = alpha.resize(rgba.size, Image.LANCZOS)
579
  alpha = alpha.filter(ImageFilter.GaussianBlur(radius=0.8))
580
-
581
  r, g, b, _a = rgba.split()
582
  return Image.merge("RGBA", (r, g, b, alpha))
583
  except Exception as exc:
@@ -589,18 +770,14 @@ def make_faceless_cutout(img: Image.Image) -> Image.Image:
589
  return remove_background_subject(img)
590
 
591
 
592
- def build_faceless_embroidery_assets(
593
- img: Image.Image,
594
- cfg: Optional[LineartConfig] = None,
595
- **kwargs
596
- ) -> Tuple[Image.Image, Image.Image]:
597
  if cfg is None:
598
  cfg = LineartConfig()
599
  if "prompt" in kwargs and kwargs["prompt"] is not None:
600
  cfg.prompt = kwargs["prompt"]
601
  if "negative_prompt" in kwargs and kwargs["negative_prompt"] is not None:
602
  cfg.negative_prompt = kwargs["negative_prompt"]
603
-
604
  lineart = make_faceless_lineart(img, cfg)
605
  cutout = make_faceless_cutout(img)
606
  return lineart, cutout
 
10
  from rembg import remove, new_session
11
  import threading
12
 
13
+ try:
14
+ from scipy.interpolate import splprep, splev
15
+ _SCIPY_AVAILABLE = True
16
+ except ImportError:
17
+ _SCIPY_AVAILABLE = False
18
+ print("[AI] scipy not available — falling back to approxPolyDP for silhouette smoothing")
19
+
20
  try:
21
  _SESSION_ISNET = new_session("isnet-general-use")
22
  except Exception as e:
23
  print(f"[AI] Failed to load rembg session: {e}")
24
  _SESSION_ISNET = None
25
 
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # Neural line-art detector (controlnet_aux / informative-drawings)
29
+ # This is what produces the clean, continuous "illustrator" strokes that a
30
+ # Canny edge detector can never achieve.
31
+ # ---------------------------------------------------------------------------
32
+ _LINEART_LOCK = threading.Lock()
33
+ _LINEART_DETECTOR = None
34
+ _LINEART_AVAILABLE = True
35
+
36
+
37
+ def get_lineart_detector():
38
+ """
39
+ Lazy-load the controlnet_aux LineartDetector (informative-drawings model
40
+ from lllyasviel/Annotators). Loaded once and cached. Moves to GPU if torch
41
+ + CUDA are available, otherwise runs on CPU.
42
+ """
43
+ global _LINEART_DETECTOR, _LINEART_AVAILABLE
44
+ if _LINEART_DETECTOR is not None or not _LINEART_AVAILABLE:
45
+ return _LINEART_DETECTOR
46
+ with _LINEART_LOCK:
47
+ if _LINEART_DETECTOR is not None:
48
+ return _LINEART_DETECTOR
49
+ try:
50
+ from controlnet_aux import LineartDetector
51
+ detector = LineartDetector.from_pretrained("lllyasviel/Annotators")
52
+ try:
53
+ import torch
54
+ if torch.cuda.is_available():
55
+ detector = detector.to("cuda")
56
+ print("[AI] LineartDetector loaded on CUDA")
57
+ else:
58
+ print("[AI] LineartDetector loaded on CPU")
59
+ except Exception:
60
+ print("[AI] LineartDetector loaded (torch device check skipped)")
61
+ _LINEART_DETECTOR = detector
62
+ except Exception as e:
63
+ print(f"[AI] controlnet_aux LineartDetector unavailable, falling back to Canny: {e}")
64
+ _LINEART_AVAILABLE = False
65
+ _LINEART_DETECTOR = None
66
+ return _LINEART_DETECTOR
67
+
68
+
69
+ # Globally download cascades and models at startup if missing
70
  def _download_assets_globally():
71
  try:
 
72
  cascade_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "cascades")
73
  os.makedirs(cascade_dir, exist_ok=True)
74
  xml_urls = {
75
+ 'haarcascade_frontalface_default.xml': 'https://raw.githubusercontent.com/opencv/opencv/master/data/haarcascades/haarcape_frontalface_default.xml',
76
  'haarcascade_frontalface_alt2.xml': 'https://raw.githubusercontent.com/opencv/opencv/master/data/haarcascades/haarcascade_frontalface_alt2.xml',
77
  'haarcascade_profileface.xml': 'https://raw.githubusercontent.com/opencv/opencv/master/data/haarcascades/haarcascade_profileface.xml'
78
  }
79
+ # Note: Fix frontalface_default URL typo if raw OpenCV has it
80
+ xml_urls['haarcascade_frontalface_default.xml'] = 'https://raw.githubusercontent.com/opencv/opencv/master/data/haarcascades/haarcascade_frontalface_default.xml'
81
+
82
  for name, url in xml_urls.items():
83
  path = os.path.join(cascade_dir, name)
84
  if not os.path.exists(path) or os.path.getsize(path) < 1000:
 
88
  except Exception as dl_err:
89
  print(f"[AI] Failed to download {name} globally: {dl_err}")
90
 
 
91
  model_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "models")
92
  os.makedirs(model_dir, exist_ok=True)
93
  model_path = os.path.join(model_dir, "face_landmarker.task")
 
103
  print(f"[AI] Global asset download manager failed: {e}")
104
 
105
  _download_assets_globally()
106
+ # Warm up the neural detector at import time (download weights once)
107
+ get_lineart_detector()
108
+
109
 
110
 
111
  @dataclass
112
  class LineartConfig:
113
+ # Backward compatibility fields
114
  prompt: str = ""
115
  negative_prompt: str = ""
116
  stable_diffusion_model_id: str = ""
 
123
  transparency_threshold: float = 0.03
124
  detect_eyebrows: bool = True
125
 
 
126
  # Subject mask
127
+ white_bg_threshold: int = 245
128
  mask_close_iterations: int = 4
129
  mask_open_iterations: int = 1
130
+ mask_smooth_ksize: int = 5
131
+ mask_smooth_threshold: int = 128
132
+ min_silhouette_area: float = 200.0
133
 
134
  # Silhouette outline
135
+ silhouette_line_thickness: int = 4
136
+
137
+ # --- Neural line-art parameters ---
138
+ lineart_detect_resolution: int = 1024
139
+ lineart_image_resolution: int = 1024
140
+ lineart_coarse: bool = True
141
+
142
+ # AUMENTADO: Força bruta para ignorar detalhes fracos
143
+ lineart_threshold: int = 120
144
+
145
+ # AUMENTADO: Faca implacável para traços curtos
146
+ lineart_min_perimeter: float = 90.0
147
+
148
+ interior_erode_size: int = 1
149
+ interior_line_thickness: int = 3
150
 
151
+ # ---- Canny fallback parametros ----
152
  canny_low: int = 35
153
+ canny_high: int = 75
154
  bilateral_d: int = 11
155
+ bilateral_sigma_color: int = 110
156
+ bilateral_sigma_space: int = 110
157
+ gaussian_ksize: int = 11
158
+ min_contour_perimeter: float = 15.0
159
+ min_embroidery_stitch_px: int = 25
160
+ enable_detail_pass: bool = False
161
+ detail_canny_low: int = 15
162
+ detail_canny_high: int = 55
163
+ detail_gaussian_ksize: int = 5
164
+ detail_min_perimeter: float = 30.0
165
+ detail_bilateral_d: int = 7
166
+ detail_bilateral_sigma: int = 60
167
 
168
  # Face detection
169
  face_scale_factor: float = 1.05
170
  face_min_neighbors: int = 3
171
+ face_min_size: Tuple[int, int] = (20, 20)
172
+ face_max_y_ratio: float = 0.65
173
+ face_nms_iou_thresh: float = 0.35
174
+ face_max_count: int = 6
175
 
176
+ # Face erase ellipse
177
+ face_cx_ratio: float = 0.50
178
  face_cy_ratio: float = 0.50
179
+ face_rx_ratio: float = 0.44
180
  face_ry_ratio: float = 0.46
181
+ face_erase_blur: int = 5
182
+ face_erase_threshold: int = 90
183
+
184
+ # MediaPipe
185
+ mediapipe_num_faces: int = 6
186
+
187
+ # --- AJUSTE DE SOBRANCELHA ---
188
+ eyebrow_thickness: int = 2
189
+ eyebrow_dilate: int = 0
190
+
191
+ # Face shape
192
+ draw_face_oval: bool = True
193
+ face_oval_thickness: int = 3
194
+
195
+ # AJUSTE FINO: Remove 45% do topo do oval (o que causava aquele erro bizarro)
196
+ face_oval_skip_top_ratio: float = 0.45
197
 
198
 
199
  def _nms_faces(faces: list, iou_thresh: float) -> list:
 
200
  if not faces:
201
  return []
202
  boxes = np.array(faces, dtype=float)
 
219
 
220
 
221
  def detect_faces(rgb: np.ndarray, cfg: LineartConfig) -> list:
 
222
  h, w = rgb.shape[:2]
223
  faces = []
224
 
 
231
  model_path = os.path.join(model_dir, "face_landmarker.task")
232
 
233
  if os.path.exists(model_path):
234
+ num_faces = getattr(cfg, 'mediapipe_num_faces', 6)
235
  with _LANDMARKER_LOCK:
236
  landmarker = get_landmarker(model_path, num_faces=num_faces)
237
  mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=rgb)
 
301
  # 3. Filter and Deduplicate
302
  faces = [f for f in faces if f[1] < h * cfg.face_max_y_ratio]
303
  faces = _nms_faces(faces, cfg.face_nms_iou_thresh)
304
+ max_faces = getattr(cfg, 'face_max_count', 6)
305
+ if len(faces) > max_faces:
306
+ faces = sorted(faces, key=lambda f: f[2] * f[3], reverse=True)[:max_faces]
307
  return faces
308
 
309
 
310
  def build_subject_mask(rgb: np.ndarray, cfg: LineartConfig, alpha_channel: Optional[np.ndarray] = None) -> np.ndarray:
 
311
  if alpha_channel is not None:
 
312
  mask = (alpha_channel > 30).astype(np.uint8) * 255
313
  else:
314
  white_mask = np.all(rgb > cfg.white_bg_threshold, axis=2)
 
319
  return mask
320
 
321
 
322
+ def _smooth_contour_spline(cnt: np.ndarray, n_points: int = 400) -> Optional[np.ndarray]:
323
+ if not _SCIPY_AVAILABLE:
324
+ return None
325
+ pts = cnt[:, 0, :]
326
+ if len(pts) < 4:
327
+ return None
328
+ try:
329
+ tck, _ = splprep([pts[:, 0].astype(float), pts[:, 1].astype(float)],
330
+ s=len(pts) * 0.5, per=True, quiet=True)
331
+ u_new = np.linspace(0, 1, n_points)
332
+ x_new, y_new = splev(u_new, tck)
333
+ spline_pts = np.stack([x_new, y_new], axis=1).astype(np.int32)
334
+ return spline_pts.reshape((-1, 1, 2))
335
+ except Exception:
336
+ return None
337
+
338
+
339
  def build_silhouette(subject_mask: np.ndarray, cfg: LineartConfig) -> np.ndarray:
 
340
  mask = subject_mask.copy()
341
  h, w = mask.shape[:2]
342
  # Dynamic proportional smoothing based on image size to eliminate jaggies completely
 
346
  k = max(25, k)
347
  mask = cv2.GaussianBlur(mask, (k, k), 0)
348
  _, mask = cv2.threshold(mask, cfg.mask_smooth_threshold, 255, cv2.THRESH_BINARY)
349
+
350
+ k_close = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (9, 9))
351
+ mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, k_close, iterations=2)
352
 
353
+ contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)
354
  silhouette = np.zeros_like(subject_mask)
355
  for cnt in contours:
356
+ if cv2.contourArea(cnt) < cfg.min_silhouette_area:
357
+ continue
358
+ smooth = _smooth_contour_spline(cnt)
359
+ if smooth is not None:
360
+ cv2.polylines(silhouette, [smooth], isClosed=True,
361
+ color=255, thickness=cfg.silhouette_line_thickness,
362
+ lineType=cv2.LINE_AA)
363
+ else:
364
  eps = 0.0012 * cv2.arcLength(cnt, True)
365
+ approx = cv2.approxPolyDP(cnt, eps, True)
366
+ cv2.drawContours(silhouette, [approx], -1, 255,
367
+ cfg.silhouette_line_thickness, lineType=cv2.LINE_AA)
368
  return silhouette
369
 
370
 
371
  def build_face_erase_mask(rgb: np.ndarray, faces: list, cfg: LineartConfig) -> np.ndarray:
 
372
  h, w = rgb.shape[:2]
373
  mask = np.zeros((h, w), dtype=np.uint8)
374
 
 
382
  model_path = os.path.join(model_dir, "face_landmarker.task")
383
 
384
  if os.path.exists(model_path):
385
+ num_faces = getattr(cfg, 'mediapipe_num_faces', 6)
386
  with _LANDMARKER_LOCK:
387
  landmarker = get_landmarker(model_path, num_faces=num_faces)
388
  mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=rgb)
 
418
  return (soft > cfg.face_erase_threshold).astype(np.uint8) * 255
419
 
420
 
421
+ def _line_strength_from_detector(pil_lineart: Image.Image, target_hw: Tuple[int, int]) -> np.ndarray:
422
+ h, w = target_hw
423
+ gray = np.array(pil_lineart.convert("L"))
424
+ corners = [gray[0, 0], gray[0, -1], gray[-1, 0], gray[-1, -1]]
425
+ bg = float(np.median(corners))
426
+ if bg > 127:
427
+ strength = 255 - gray
428
+ else:
429
+ strength = gray
430
+ if strength.shape[:2] != (h, w):
431
+ strength = cv2.resize(strength, (w, h), interpolation=cv2.INTER_LINEAR)
432
+ return strength.astype(np.uint8)
433
+
434
+
435
  def build_interior_edges(rgb: np.ndarray, subject_mask: np.ndarray, cfg: LineartConfig) -> np.ndarray:
436
+ h, w = rgb.shape[:2]
437
+ detector = get_lineart_detector()
438
+
439
+ if detector is not None:
440
+ try:
441
+ pil_in = Image.fromarray(rgb)
442
+ with _LINEART_LOCK:
443
+ pil_out = detector(
444
+ pil_in,
445
+ detect_resolution=cfg.lineart_detect_resolution,
446
+ image_resolution=cfg.lineart_image_resolution,
447
+ coarse=cfg.lineart_coarse,
448
+ )
449
+ strength = _line_strength_from_detector(pil_out, (h, w))
450
+
451
+ _, edges = cv2.threshold(strength, cfg.lineart_threshold, 255, cv2.THRESH_BINARY)
452
+
453
+ erode_size = getattr(cfg, 'interior_erode_size', 3)
454
+ erode_size = max(1, erode_size)
455
+ if erode_size % 2 == 0:
456
+ erode_size += 1
457
+ kernel_erode = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (erode_size, erode_size))
458
+ eroded_mask = cv2.erode(subject_mask, kernel_erode, iterations=1)
459
+ edges = cv2.bitwise_and(edges, eroded_mask)
460
+
461
+ # Aspirador de Sujeiras
462
+ kernel_clean = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3))
463
+ edges = cv2.morphologyEx(edges, cv2.MORPH_OPEN, kernel_clean, iterations=1)
464
+
465
+ contours, _ = cv2.findContours(edges, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)
466
+ filtered = np.zeros_like(edges)
467
+ for cnt in contours:
468
+ perimeter = cv2.arcLength(cnt, False)
469
+ area = cv2.contourArea(cnt)
470
+
471
+ # BARREIRA EXTREMA: Elimina pontos isolados e traços curtos
472
+ if perimeter < cfg.lineart_min_perimeter or area < 40.0:
473
+ continue
474
+
475
+ cv2.drawContours(filtered, [cnt], -1, 255, cfg.interior_line_thickness)
476
+
477
+ kernel_dilate = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3))
478
+ filtered = cv2.dilate(filtered, kernel_dilate, iterations=1)
479
+ filtered = cv2.GaussianBlur(filtered, (3, 3), 0)
480
+
481
+ _, filtered = cv2.threshold(filtered, 127, 255, cv2.THRESH_BINARY)
482
+
483
+ return filtered
484
+ except Exception as e:
485
+ print(f"[AI] Neural lineart failed, falling back to Canny: {e}")
486
+
487
+ return _build_interior_edges_canny(rgb, subject_mask, cfg)
488
+
489
+
490
+ def _build_interior_edges_canny(rgb: np.ndarray, subject_mask: np.ndarray, cfg: LineartConfig) -> np.ndarray:
491
  gray = cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY)
492
+ blurred = cv2.bilateralFilter(gray, cfg.bilateral_d, cfg.bilateral_sigma_color, cfg.bilateral_sigma_space)
493
+ blurred = cv2.GaussianBlur(blurred, (cfg.gaussian_ksize, cfg.gaussian_ksize), 0)
494
  edges = cv2.Canny(blurred, cfg.canny_low, cfg.canny_high)
495
+
 
496
  erode_size = max(5, cfg.silhouette_line_thickness * 2 + 3)
497
  if erode_size % 2 == 0:
498
  erode_size += 1
 
500
  eroded_mask = cv2.erode(subject_mask, kernel_erode, iterations=1)
501
  edges = cv2.bitwise_and(edges, eroded_mask)
502
 
 
503
  contours, _ = cv2.findContours(edges, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)
504
  filtered = np.zeros_like(edges)
505
  for cnt in contours:
506
  perimeter = cv2.arcLength(cnt, False)
507
+ if perimeter < cfg.min_contour_perimeter:
508
+ continue
509
+ eps = 0.0012 * perimeter
510
+ smooth = cv2.approxPolyDP(cnt, eps, False)
511
+ cv2.drawContours(filtered, [smooth], -1, 255, cfg.interior_line_thickness)
 
 
512
  kernel_dilate = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3))
513
  filtered = cv2.dilate(filtered, kernel_dilate, iterations=1)
514
  return filtered
 
518
  _LANDMARKER_CACHE = None
519
  _LANDMARKER_NUM_FACES = None
520
 
521
+ def get_landmarker(model_path, num_faces: int = 6):
522
  global _LANDMARKER_CACHE, _LANDMARKER_NUM_FACES
523
  if _LANDMARKER_CACHE is None or _LANDMARKER_NUM_FACES != num_faces:
524
  import mediapipe as mp
525
  from mediapipe.tasks import python
526
  from mediapipe.tasks.python import vision
 
527
  base_options = python.BaseOptions(model_asset_path=model_path)
528
  options = vision.FaceLandmarkerOptions(
529
  base_options=base_options,
 
540
 
541
 
542
  def draw_eyebrows(rgb: np.ndarray, cfg: LineartConfig, faces: Optional[list] = None) -> np.ndarray:
 
543
  h, w, _ = rgb.shape
544
  eyebrows_layer = np.zeros((h, w), dtype=np.uint8)
 
545
  try:
546
  import mediapipe as mp
 
 
547
 
 
548
  model_path = cfg.mediapipe_model_path
549
  if not model_path or not os.path.exists(model_path):
550
  model_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "models")
 
554
  print("[AI] Downloading face_landmarker.task for eyebrows...")
555
  model_url = "https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task"
556
  urllib.request.urlretrieve(model_url, model_path)
 
557
 
558
+ LEFT_EYEBROW = [70, 63, 105, 66, 107, 55, 65, 52, 53, 46]
559
  RIGHT_EYEBROW = [336, 296, 334, 293, 300, 285, 295, 282, 283, 276]
560
+ FACE_OVAL = [10, 338, 297, 332, 284, 251, 389, 356, 454, 323, 361, 288,
561
+ 397, 365, 379, 378, 400, 377, 152, 148, 176, 149, 150, 136,
562
+ 172, 58, 132, 93, 234, 127, 162, 21, 54, 103, 67, 109]
563
+
564
+ eyebrow_thickness = getattr(cfg, 'eyebrow_thickness', cfg.interior_line_thickness)
565
+ draw_oval = True
566
+ oval_thickness = getattr(cfg, 'face_oval_thickness', 2)
567
+ oval_skip_top = getattr(cfg, 'face_oval_skip_top_ratio', 0.18)
568
+ drawn_centers_px: list = []
569
+
570
+ def is_duplicate_px(cx_px: float, cy_px: float) -> bool:
571
+ min_dist = min(w, h) * 0.06
572
+ for (pcx, pcy) in drawn_centers_px:
573
+ if np.hypot(cx_px - pcx, cy_px - pcy) < min_dist:
574
  return True
575
  return False
576
 
577
+ def draw_eyebrow_landmarks(face_lms, origin_x, origin_y, scale_x, scale_y):
578
+ for indices in [LEFT_EYEBROW, RIGHT_EYEBROW]:
579
+ pts = []
580
+ for idx in indices:
581
+ lm = face_lms[idx]
582
+ px = int(origin_x + lm.x * scale_x)
583
+ py = int(origin_y + lm.y * scale_y)
584
+ pts.append([px, py])
585
+ pts_np = np.array(pts, np.int32).reshape((-1, 1, 2))
586
+ cv2.polylines(eyebrows_layer, [pts_np], isClosed=False, color=255,
587
+ thickness=eyebrow_thickness, lineType=cv2.LINE_AA)
588
+
589
+ if draw_oval:
590
+ oval_pts = []
591
+ for idx in FACE_OVAL:
592
+ lm = face_lms[idx]
593
+ px = int(origin_x + lm.x * scale_x)
594
+ py = int(origin_y + lm.y * scale_y)
595
+ oval_pts.append([px, py])
596
+ oval_np = np.array(oval_pts, np.int32)
597
+ if len(oval_np) >= 4:
598
+ if oval_skip_top > 0:
599
+ y_min = oval_np[:, 1].min()
600
+ y_max = oval_np[:, 1].max()
601
+ cutoff = y_min + (y_max - y_min) * oval_skip_top
602
+ kept = oval_np[oval_np[:, 1] >= cutoff]
603
+ if len(kept) >= 4:
604
+ arc = kept.reshape((-1, 1, 2))
605
+ cv2.polylines(eyebrows_layer, [arc], isClosed=False,
606
+ color=255, thickness=oval_thickness,
607
+ lineType=cv2.LINE_AA)
608
+ else:
609
+ cv2.polylines(eyebrows_layer, [oval_np.reshape((-1, 1, 2))],
610
+ isClosed=True, color=255,
611
+ thickness=oval_thickness, lineType=cv2.LINE_AA)
612
+
613
  if faces is None:
614
  faces = detect_faces(rgb, cfg)
615
 
616
  with _LANDMARKER_LOCK:
617
+ num_faces = getattr(cfg, 'mediapipe_num_faces', 6)
618
+ landmarker = get_landmarker(model_path, num_faces=num_faces)
619
+
620
  mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=rgb)
621
  global_result = landmarker.detect(mp_image)
622
  if global_result.face_landmarks:
623
+ for face_lms in global_result.face_landmarks:
624
+ cx_px = np.mean([lm.x for lm in face_lms]) * w
625
+ cy_px = np.mean([lm.y for lm in face_lms]) * h
626
+ if not is_duplicate_px(cx_px, cy_px):
627
+ drawn_centers_px.append((cx_px, cy_px))
628
+ draw_eyebrow_landmarks(face_lms, 0, 0, w, h)
629
+
630
+ for (fx, fy, fw, fh) in faces:
631
+ mx = int(fw * 0.50); my = int(fh * 0.50)
632
+ x1 = max(0, fx - mx); y1 = max(0, fy - my)
633
+ x2 = min(w, fx + fw + mx); y2 = min(h, fy + fh + my)
634
+ crop_w, crop_h = x2 - x1, y2 - y1
635
+ if crop_w < 20 or crop_h < 20:
 
 
 
 
 
 
 
 
 
 
 
 
 
636
  continue
637
+ face_cx_px = (x1 + x2) / 2; face_cy_px = (y1 + y2) / 2
638
+ if is_duplicate_px(face_cx_px, face_cy_px):
 
 
639
  continue
 
640
  crop = rgb[y1:y2, x1:x2]
641
+ target = max(512, crop_w, crop_h)
642
+ scale = target / max(crop_w, crop_h)
643
+ cw_scaled = int(crop_w * scale); ch_scaled = int(crop_h * scale)
644
+ crop_resized = cv2.resize(crop, (cw_scaled, ch_scaled), interpolation=cv2.INTER_CUBIC)
645
  crop_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=crop_resized)
646
  crop_result = landmarker.detect(crop_image)
 
647
  if crop_result.face_landmarks:
648
+ face_lms = crop_result.face_landmarks[0]
649
+ cx_px2 = x1 + np.mean([lm.x for lm in face_lms]) * crop_w
650
+ cy_px2 = y1 + np.mean([lm.y for lm in face_lms]) * crop_h
651
+ if is_duplicate_px(cx_px2, cy_px2):
652
+ continue
653
+ drawn_centers_px.append((cx_px2, cy_px2))
654
+ draw_eyebrow_landmarks(face_lms, x1, y1, crop_w, crop_h)
655
+
656
+ print(f"[eyebrows] Drew eyebrows for {len(drawn_centers_px)} face(s)")
 
 
 
 
 
 
657
  except Exception as e:
658
  print(f"[AI] Eyebrow extraction error or library missing: {e}")
659
 
660
+ if eyebrows_layer.any():
661
+ dil = getattr(cfg, 'eyebrow_dilate', 1)
662
+ if dil > 0:
663
+ kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3))
664
+ eyebrows_layer = cv2.dilate(eyebrows_layer, kernel, iterations=dil)
665
  return eyebrows_layer
666
 
667
 
668
+ def make_faceless_lineart(img: Image.Image, cfg: LineartConfig = None) -> Image.Image:
 
 
 
 
 
 
 
669
  cfg = cfg or LineartConfig()
670
+
 
671
  alpha_channel: Optional[np.ndarray] = None
672
  if img.mode in ("RGBA", "LA") or (img.mode == "P" and "transparency" in img.info):
673
  rgba_img = img.convert("RGBA")
674
  alpha_np = np.array(rgba_img)[:, :, 3]
 
675
  if np.any(alpha_np < 200):
676
  alpha_channel = alpha_np
677
  background = Image.new("RGBA", img.size, (255, 255, 255, 255))
 
684
  rgb = img_np[:, :, :3]
685
  h, w = rgb.shape[:2]
686
 
687
+ if alpha_channel is None and _SESSION_ISNET is not None:
688
+ try:
689
+ cut = remove(Image.fromarray(rgb).convert("RGBA"), session=_SESSION_ISNET, only_mask=True)
690
+ cut_l = cut.convert("L") if isinstance(cut, Image.Image) else Image.open(io.BytesIO(cut)).convert("L")
691
+ if cut_l.size != (w, h):
692
+ cut_l = cut_l.resize((w, h), Image.LANCZOS)
693
+ alpha_channel = np.array(cut_l)
694
+ except Exception as e:
695
+ print(f"[AI] rembg mask for lineart failed: {e}")
696
 
697
+ subject_mask = build_subject_mask(rgb, cfg, alpha_channel)
698
  faces = detect_faces(rgb, cfg)
699
  print(f"[lineart] Detected {len(faces)} face(s)")
700
 
 
701
  silhouette = build_silhouette(subject_mask, cfg)
 
 
702
  interior = build_interior_edges(rgb, subject_mask, cfg)
703
 
 
704
  face_erase = build_face_erase_mask(rgb, faces, cfg)
705
  interior_clean = cv2.bitwise_and(interior, cv2.bitwise_not(face_erase))
706
 
 
707
  final_lines = cv2.add(silhouette, interior_clean)
708
 
 
709
  if cfg.detect_eyebrows:
710
  eyebrows = draw_eyebrows(rgb, cfg, faces=faces)
711
  final_lines = cv2.add(final_lines, eyebrows)
712
 
 
713
  border_erase_width = max(cfg.silhouette_line_thickness, cfg.interior_line_thickness) + 2
714
  if border_erase_width > 0:
715
  final_lines[0:border_erase_width, :] = 0
 
717
  final_lines[:, 0:border_erase_width] = 0
718
  final_lines[:, -border_erase_width:] = 0
719
 
 
 
720
  alpha = Image.fromarray(final_lines)
721
+ transparent = Image.new("RGBA", img.size, (0, 0, 0, 0))
 
 
722
  transparent.putalpha(alpha)
 
723
  return transparent
724
 
725
 
 
752
  rgba = img.convert("RGBA")
753
  if _has_real_transparency(rgba):
754
  return rgba
 
755
  if _SESSION_ISNET is None:
756
  return rgba
 
757
  mask = remove(rgba, session=_SESSION_ISNET, only_mask=True)
758
  alpha = mask.convert("L") if isinstance(mask, Image.Image) else Image.open(io.BytesIO(mask)).convert("L")
759
  if alpha.size != rgba.size:
760
  alpha = alpha.resize(rgba.size, Image.LANCZOS)
761
  alpha = alpha.filter(ImageFilter.GaussianBlur(radius=0.8))
 
762
  r, g, b, _a = rgba.split()
763
  return Image.merge("RGBA", (r, g, b, alpha))
764
  except Exception as exc:
 
770
  return remove_background_subject(img)
771
 
772
 
773
+
774
+ def build_faceless_embroidery_assets(img: Image.Image, cfg: Optional[LineartConfig] = None, **kwargs) -> Tuple[Image.Image, Image.Image]:
 
 
 
775
  if cfg is None:
776
  cfg = LineartConfig()
777
  if "prompt" in kwargs and kwargs["prompt"] is not None:
778
  cfg.prompt = kwargs["prompt"]
779
  if "negative_prompt" in kwargs and kwargs["negative_prompt"] is not None:
780
  cfg.negative_prompt = kwargs["negative_prompt"]
 
781
  lineart = make_faceless_lineart(img, cfg)
782
  cutout = make_faceless_cutout(img)
783
  return lineart, cutout