import cv2 import numpy as np from PIL import Image, ImageFilter from dataclasses import dataclass from typing import Tuple, Optional import io import base64 import os import urllib.request from rembg import remove, new_session import threading try: from scipy.interpolate import splprep, splev _SCIPY_AVAILABLE = True except ImportError: _SCIPY_AVAILABLE = False print("[AI] scipy not available — falling back to approxPolyDP for silhouette smoothing") try: _SESSION_ISNET = new_session("isnet-general-use") except Exception as e: print(f"[AI] Failed to load rembg session: {e}") _SESSION_ISNET = None # --------------------------------------------------------------------------- # Neural line-art detector (controlnet_aux / informative-drawings) # This is what produces the clean, continuous "illustrator" strokes that a # Canny edge detector can never achieve. # --------------------------------------------------------------------------- _LINEART_LOCK = threading.Lock() _LINEART_DETECTOR = None _LINEART_AVAILABLE = True def get_lineart_detector(): """ Lazy-load the controlnet_aux LineartDetector (informative-drawings model from lllyasviel/Annotators). Loaded once and cached. Moves to GPU if torch + CUDA are available, otherwise runs on CPU. """ global _LINEART_DETECTOR, _LINEART_AVAILABLE if _LINEART_DETECTOR is not None or not _LINEART_AVAILABLE: return _LINEART_DETECTOR with _LINEART_LOCK: if _LINEART_DETECTOR is not None: return _LINEART_DETECTOR try: from controlnet_aux import LineartDetector detector = LineartDetector.from_pretrained("lllyasviel/Annotators") try: import torch if torch.cuda.is_available(): detector = detector.to("cuda") print("[AI] LineartDetector loaded on CUDA") else: print("[AI] LineartDetector loaded on CPU") except Exception: print("[AI] LineartDetector loaded (torch device check skipped)") _LINEART_DETECTOR = detector except Exception as e: print(f"[AI] controlnet_aux LineartDetector unavailable, falling back to Canny: {e}") _LINEART_AVAILABLE = False _LINEART_DETECTOR = None return _LINEART_DETECTOR # Globally download cascades and models at startup if missing def _download_assets_globally(): try: cascade_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "cascades") os.makedirs(cascade_dir, exist_ok=True) xml_urls = { 'haarcascade_frontalface_default.xml': 'https://raw.githubusercontent.com/opencv/opencv/master/data/haarcascades/haarcascade_frontalface_default.xml', 'haarcascade_frontalface_alt2.xml': 'https://raw.githubusercontent.com/opencv/opencv/master/data/haarcascades/haarcascade_frontalface_alt2.xml' } for name, url in xml_urls.items(): path = os.path.join(cascade_dir, name) if not os.path.exists(path) or os.path.getsize(path) < 1000: print(f"[AI] Downloading cascade {name} globally from OpenCV GitHub...") try: urllib.request.urlretrieve(url, path) except Exception as dl_err: print(f"[AI] Failed to download {name} globally: {dl_err}") model_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "models") os.makedirs(model_dir, exist_ok=True) model_path = os.path.join(model_dir, "face_landmarker.task") if not os.path.exists(model_path) or os.path.getsize(model_path) < 1000000: print("[AI] Downloading face_landmarker.task globally...") try: model_url = "https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task" urllib.request.urlretrieve(model_url, model_path) print("[AI] face_landmarker.task download complete!") except Exception as dl_err: print(f"[AI] Failed to download face_landmarker.task globally: {dl_err}") except Exception as e: print(f"[AI] Global asset download manager failed: {e}") _download_assets_globally() # Warm up the neural detector at import time (download weights once) get_lineart_detector() @dataclass class LineartConfig: # Backward compatibility fields prompt: str = "" negative_prompt: str = "" stable_diffusion_model_id: str = "" controlnet_model_id: str = "" num_inference_steps: int = 10 controlnet_conditioning_scale: float = 0.9 face_margin_ratio: float = 0.035 mediapipe_model_path: str = "" rembg_model: str = "" transparency_threshold: float = 0.03 detect_eyebrows: bool = True # Subject mask white_bg_threshold: int = 245 mask_close_iterations: int = 4 mask_open_iterations: int = 1 mask_smooth_ksize: int = 5 mask_smooth_threshold: int = 128 min_silhouette_area: float = 200.0 # Silhouette outline silhouette_line_thickness: int = 3 # --- Neural line-art parameters --- lineart_detect_resolution: int = 2048 lineart_image_resolution: int = 2048 lineart_coarse: bool = True # AUMENTADO: Força a ignorar dobras de roupa e focar só na estrutura lineart_threshold: int = 100 # AJUSTE DE CABELO E CORPO: Diminuído para capturar fios e dobras de roupa. lineart_min_perimeter: float = 70.0 # Diminuído para manter os traços mais próximos da borda. interior_erode_size: int = 1 # AJUSTE GERAL: Afinado para traços internos mais delicados. interior_line_thickness: int = 2 # ---- Canny fallback parametros ---- canny_low: int = 35 canny_high: int = 75 bilateral_d: int = 11 bilateral_sigma_color: int = 110 bilateral_sigma_space: int = 110 gaussian_ksize: int = 11 min_contour_perimeter: float = 15.0 min_embroidery_stitch_px: int = 25 enable_detail_pass: bool = False detail_canny_low: int = 15 detail_canny_high: int = 55 detail_gaussian_ksize: int = 5 detail_min_perimeter: float = 30.0 detail_bilateral_d: int = 7 detail_bilateral_sigma: int = 60 # Face detection face_scale_factor: float = 1.05 face_min_neighbors: int = 3 face_min_size: Tuple[int, int] = (20, 20) face_max_y_ratio: float = 0.65 face_nms_iou_thresh: float = 0.35 face_max_count: int = 6 # Face erase ellipse face_cx_ratio: float = 0.50 face_cy_ratio: float = 0.55 face_rx_ratio: float = 0.35 face_ry_ratio: float = 0.40 face_erase_blur: int = 5 face_erase_threshold: int = 90 # MediaPipe mediapipe_num_faces: int = 6 # --- AJUSTE DE SOBRANCELHA --- # Reduzido drasticamente para um traço fino e sutil. eyebrow_thickness: int = 2 # Zerado (era 1). Isso remove o "borrão" que deixava a sobrancelha grossa demais. eyebrow_dilate: int = 0 # Face shape draw_face_oval: bool = True face_oval_thickness: int = 1 face_oval_skip_top_ratio: float = 0.50 def _nms_faces(faces: list, iou_thresh: float) -> list: if not faces: return [] boxes = np.array(faces, dtype=float) x1, y1 = boxes[:, 0], boxes[:, 1] x2, y2 = boxes[:, 0] + boxes[:, 2], boxes[:, 1] + boxes[:, 3] areas = (x2 - x1) * (y2 - y1) order = areas.argsort()[::-1] keep = [] while len(order) > 0: i = order[0] keep.append(i) xx1 = np.maximum(x1[i], x1[order[1:]]) yy1 = np.maximum(y1[i], y1[order[1:]]) xx2 = np.minimum(x2[i], x2[order[1:]]) yy2 = np.minimum(y2[i], y2[order[1:]]) inter = np.maximum(0, xx2 - xx1) * np.maximum(0, yy2 - yy1) iou = inter / (areas[i] + areas[order[1:]] - inter) order = order[1:][iou < iou_thresh] return [faces[i] for i in keep] def detect_faces(rgb: np.ndarray, cfg: LineartConfig) -> list: h = rgb.shape[0] gray = cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY) clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) gray_eq = clahe.apply(gray) faces = [] try: cascade_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "cascades") path_default = os.path.join(cascade_dir, 'haarcascade_frontalface_default.xml') path_alt = os.path.join(cascade_dir, 'haarcascade_frontalface_alt2.xml') classifiers = [] if os.path.exists(path_default): classifiers.append(cv2.CascadeClassifier(path_default)) if os.path.exists(path_alt): classifiers.append(cv2.CascadeClassifier(path_alt)) if not classifiers or all(c.empty() for c in classifiers): classifiers = [ cv2.CascadeClassifier(cv2.data.haarcascades + 'haarcascade_frontalface_default.xml'), cv2.CascadeClassifier(cv2.data.haarcascades + 'haarcascade_frontalface_alt2.xml') ] for cascade in classifiers: if cascade and not cascade.empty(): for img_gray in [gray, gray_eq]: detected = cascade.detectMultiScale( img_gray, scaleFactor=cfg.face_scale_factor, minNeighbors=cfg.face_min_neighbors, minSize=cfg.face_min_size ) if len(detected) > 0: faces.extend(detected.tolist()) except Exception as e: print(f"[AI] Face detection failed or cascades unavailable: {e}") faces = [f for f in faces if f[1] < h * cfg.face_max_y_ratio] faces = _nms_faces(faces, cfg.face_nms_iou_thresh) max_faces = getattr(cfg, 'face_max_count', 6) if len(faces) > max_faces: faces = sorted(faces, key=lambda f: f[2] * f[3], reverse=True)[:max_faces] return faces def build_subject_mask(rgb: np.ndarray, cfg: LineartConfig, alpha_channel: Optional[np.ndarray] = None) -> np.ndarray: if alpha_channel is not None: mask = (alpha_channel > 30).astype(np.uint8) * 255 else: white_mask = np.all(rgb > cfg.white_bg_threshold, axis=2) mask = (~white_mask).astype(np.uint8) * 255 kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (7, 7)) mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel, iterations=cfg.mask_close_iterations) mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel, iterations=cfg.mask_open_iterations) return mask def _smooth_contour_spline(cnt: np.ndarray, n_points: int = 400) -> Optional[np.ndarray]: if not _SCIPY_AVAILABLE: return None pts = cnt[:, 0, :] if len(pts) < 4: return None try: tck, _ = splprep([pts[:, 0].astype(float), pts[:, 1].astype(float)], s=len(pts) * 0.5, per=True, quiet=True) u_new = np.linspace(0, 1, n_points) x_new, y_new = splev(u_new, tck) spline_pts = np.stack([x_new, y_new], axis=1).astype(np.int32) return spline_pts.reshape((-1, 1, 2)) except Exception: return None def build_silhouette(subject_mask: np.ndarray, cfg: LineartConfig) -> np.ndarray: mask = subject_mask.copy() if cfg.mask_smooth_ksize > 1: k = max(21, cfg.mask_smooth_ksize if cfg.mask_smooth_ksize % 2 == 1 else cfg.mask_smooth_ksize + 1) mask = cv2.GaussianBlur(mask, (k, k), 0) _, mask = cv2.threshold(mask, cfg.mask_smooth_threshold, 255, cv2.THRESH_BINARY) k_close = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (9, 9)) mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, k_close, iterations=2) contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE) silhouette = np.zeros_like(subject_mask) for cnt in contours: if cv2.contourArea(cnt) < cfg.min_silhouette_area: continue smooth = _smooth_contour_spline(cnt) if smooth is not None: cv2.polylines(silhouette, [smooth], isClosed=True, color=255, thickness=cfg.silhouette_line_thickness, lineType=cv2.LINE_AA) else: eps = 0.0003 * cv2.arcLength(cnt, True) approx = cv2.approxPolyDP(cnt, eps, True) cv2.drawContours(silhouette, [approx], -1, 255, cfg.silhouette_line_thickness, lineType=cv2.LINE_AA) return silhouette def build_face_erase_mask(faces: list, shape: Tuple[int, int], cfg: LineartConfig) -> np.ndarray: h, w = shape mask = np.zeros((h, w), dtype=np.uint8) for (fx, fy, fw, fh) in faces: cx = int(fx + fw * cfg.face_cx_ratio) cy = int(fy + fh * cfg.face_cy_ratio) rx = int(fw * cfg.face_rx_ratio) ry = int(fh * cfg.face_ry_ratio) cv2.ellipse(mask, (cx, cy), (rx, ry), 0, 0, 360, 255, -1) soft = np.array(Image.fromarray(mask).filter(ImageFilter.GaussianBlur(cfg.face_erase_blur))) return (soft > cfg.face_erase_threshold).astype(np.uint8) * 255 def _line_strength_from_detector(pil_lineart: Image.Image, target_hw: Tuple[int, int]) -> np.ndarray: """ Convert the detector output into a polarity-normalised "line strength" map where lines = bright (255) on a black (0) background, resized to target. Handles either polarity (black-on-white or white-on-black). """ h, w = target_hw gray = np.array(pil_lineart.convert("L")) # Determine background from the image corners corners = [gray[0, 0], gray[0, -1], gray[-1, 0], gray[-1, -1]] bg = float(np.median(corners)) if bg > 127: strength = 255 - gray # dark lines on light bg -> invert else: strength = gray # already light lines on dark bg if strength.shape[:2] != (h, w): strength = cv2.resize(strength, (w, h), interpolation=cv2.INTER_LINEAR) return strength.astype(np.uint8) def build_interior_edges(rgb: np.ndarray, subject_mask: np.ndarray, cfg: LineartConfig) -> np.ndarray: """ Detect structural lines (clothing folds, lapels, collars, cuffs, hair) using the neural LineartDetector. Produces continuous, illustrator-style strokes. Falls back to the legacy Canny pipeline if controlnet_aux is unavailable. """ h, w = rgb.shape[:2] detector = get_lineart_detector() if detector is not None: try: pil_in = Image.fromarray(rgb) with _LINEART_LOCK: pil_out = detector( pil_in, detect_resolution=cfg.lineart_detect_resolution, image_resolution=cfg.lineart_image_resolution, coarse=cfg.lineart_coarse, ) strength = _line_strength_from_detector(pil_out, (h, w)) # Threshold to a binary line map _, edges = cv2.threshold(strength, cfg.lineart_threshold, 255, cv2.THRESH_BINARY) # Keep lines only inside the subject. Use a SMALL erosion so that # face-shape, hairline and jaw lines (which sit close to the # silhouette) are preserved instead of being eaten away. erode_size = getattr(cfg, 'interior_erode_size', 3) erode_size = max(1, erode_size) if erode_size % 2 == 0: erode_size += 1 kernel_erode = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (erode_size, erode_size)) eroded_mask = cv2.erode(subject_mask, kernel_erode, iterations=1) edges = cv2.bitwise_and(edges, eroded_mask) # Remove speckle: drop tiny contours contours, _ = cv2.findContours(edges, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE) filtered = np.zeros_like(edges) for cnt in contours: if cv2.arcLength(cnt, False) < cfg.lineart_min_perimeter: continue cv2.drawContours(filtered, [cnt], -1, 255, cfg.interior_line_thickness) # Gentle dilation for solid, embroiderable strokes ernel_dilate = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)) filtered = cv2.dilate(filtered, kernel_dilate, iterations=1) filtered = cv2.GaussianBlur(filtered, (3, 3), 0) filtered = cv2.threshold(filtered, 127, 255, cv2.THRESH_BINARY) return filtered except Exception as e: print(f"[AI] Neural lineart failed, falling back to Canny: {e}") # ---------------- Legacy Canny fallback ---------------- return _build_interior_edges_canny(rgb, subject_mask, cfg) def _build_interior_edges_canny(rgb: np.ndarray, subject_mask: np.ndarray, cfg: LineartConfig) -> np.ndarray: gray = cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY) blurred = cv2.bilateralFilter(gray, cfg.bilateral_d, cfg.bilateral_sigma_color, cfg.bilateral_sigma_space) blurred = cv2.GaussianBlur(blurred, (cfg.gaussian_ksize, cfg.gaussian_ksize), 0) edges = cv2.Canny(blurred, cfg.canny_low, cfg.canny_high) erode_size = max(5, cfg.silhouette_line_thickness * 2 + 3) if erode_size % 2 == 0: erode_size += 1 kernel_erode = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (erode_size, erode_size)) eroded_mask = cv2.erode(subject_mask, kernel_erode, iterations=1) edges = cv2.bitwise_and(edges, eroded_mask) contours, _ = cv2.findContours(edges, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE) filtered = np.zeros_like(edges) for cnt in contours: perimeter = cv2.arcLength(cnt, False) if perimeter < cfg.min_contour_perimeter: continue eps = 0.0012 * perimeter smooth = cv2.approxPolyDP(cnt, eps, False) cv2.drawContours(filtered, [smooth], -1, 255, cfg.interior_line_thickness) kernel_dilate = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)) filtered = cv2.dilate(filtered, kernel_dilate, iterations=1) return filtered _LANDMARKER_LOCK = threading.Lock() _LANDMARKER_CACHE = None _LANDMARKER_NUM_FACES = None def get_landmarker(model_path, num_faces: int = 6): global _LANDMARKER_CACHE, _LANDMARKER_NUM_FACES if _LANDMARKER_CACHE is None or _LANDMARKER_NUM_FACES != num_faces: import mediapipe as mp from mediapipe.tasks import python from mediapipe.tasks.python import vision base_options = python.BaseOptions(model_asset_path=model_path) options = vision.FaceLandmarkerOptions( base_options=base_options, output_face_blendshapes=False, output_facial_transformation_matrixes=False, num_faces=num_faces, # Lower confidence thresholds so smiling / tilted / smaller faces # are still detected and their eyebrows get drawn. min_face_detection_confidence=0.2, min_face_presence_confidence=0.2, min_tracking_confidence=0.2, ) _LANDMARKER_CACHE = vision.FaceLandmarker.create_from_options(options) _LANDMARKER_NUM_FACES = num_faces return _LANDMARKER_CACHE def draw_eyebrows(rgb: np.ndarray, cfg: LineartConfig, faces: Optional[list] = None) -> np.ndarray: h, w, _ = rgb.shape eyebrows_layer = np.zeros((h, w), dtype=np.uint8) try: import mediapipe as mp model_path = cfg.mediapipe_model_path if not model_path or not os.path.exists(model_path): model_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "models") os.makedirs(model_dir, exist_ok=True) model_path = os.path.join(model_dir, "face_landmarker.task") if not os.path.exists(model_path) or os.path.getsize(model_path) < 1000000: print("[AI] Downloading face_landmarker.task for eyebrows...") model_url = "https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task" urllib.request.urlretrieve(model_url, model_path) LEFT_EYEBROW = [70, 63, 105, 66, 107, 55, 65, 52, 53, 46] RIGHT_EYEBROW = [336, 296, 334, 293, 300, 285, 295, 282, 283, 276] # MediaPipe face oval (head/jaw outline), ordered around the perimeter. FACE_OVAL = [10, 338, 297, 332, 284, 251, 389, 356, 454, 323, 361, 288, 397, 365, 379, 378, 400, 377, 152, 148, 176, 149, 150, 136, 172, 58, 132, 93, 234, 127, 162, 21, 54, 103, 67, 109] eyebrow_thickness = getattr(cfg, 'eyebrow_thickness', cfg.interior_line_thickness) draw_oval = True oval_thickness = getattr(cfg, 'face_oval_thickness', 2) oval_skip_top = getattr(cfg, 'face_oval_skip_top_ratio', 0.18) drawn_centers_px: list = [] def is_duplicate_px(cx_px: float, cy_px: float) -> bool: min_dist = min(w, h) * 0.06 for (pcx, pcy) in drawn_centers_px: if np.hypot(cx_px - pcx, cy_px - pcy) < min_dist: return True return False def draw_eyebrow_landmarks(face_lms, origin_x, origin_y, scale_x, scale_y): # Eyebrows for indices in [LEFT_EYEBROW, RIGHT_EYEBROW]: pts = [] for idx in indices: lm = face_lms[idx] px = int(origin_x + lm.x * scale_x) py = int(origin_y + lm.y * scale_y) pts.append([px, py]) pts_np = np.array(pts, np.int32).reshape((-1, 1, 2)) cv2.polylines(eyebrows_layer, [pts_np], isClosed=False, color=255, thickness=eyebrow_thickness, lineType=cv2.LINE_AA) # Face oval (head / jaw outline) — gives the "formato do rosto" if draw_oval: oval_pts = [] for idx in FACE_OVAL: lm = face_lms[idx] px = int(origin_x + lm.x * scale_x) py = int(origin_y + lm.y * scale_y) oval_pts.append([px, py]) oval_np = np.array(oval_pts, np.int32) if len(oval_np) >= 4: # LÓGICA REVISADA: Focar apenas no terço inferior (queixo/maxilar) y_min = oval_np[:, 1].min() y_max = oval_np[:, 1].max() # Corta os 60% superiores do rosto (mantém apenas o "U" inferior) cutoff = y_min + (y_max - y_min) * 0.60 kept = oval_np[oval_np[:, 1] >= cutoff] if len(kept) >= 4: # Ordena os pontos da esquerda para a direita para evitar o polígono maluco kept = kept[np.argsort(kept[:, 0])] arc = kept.reshape((-1, 1, 2)) # Desenha o maxilar sem fechar a forma cv2.polylines(eyebrows_layer, [arc], isClosed=False, color=255, thickness=oval_thickness, lineType=cv2.LINE_AA) else: cv2.polylines(eyebrows_layer, [oval_np.reshape((-1, 1, 2))], isClosed=True, color=255, thickness=oval_thickness, lineType=cv2.LINE_AA) if faces is None: faces = detect_faces(rgb, cfg) with _LANDMARKER_LOCK: num_faces = getattr(cfg, 'mediapipe_num_faces', 6) landmarker = get_landmarker(model_path, num_faces=num_faces) mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=rgb) global_result = landmarker.detect(mp_image) if global_result.face_landmarks: for face_lms in global_result.face_landmarks: cx_px = np.mean([lm.x for lm in face_lms]) * w cy_px = np.mean([lm.y for lm in face_lms]) * h if not is_duplicate_px(cx_px, cy_px): drawn_centers_px.append((cx_px, cy_px)) draw_eyebrow_landmarks(face_lms, 0, 0, w, h) for (fx, fy, fw, fh) in faces: mx = int(fw * 0.50); my = int(fh * 0.50) x1 = max(0, fx - mx); y1 = max(0, fy - my) x2 = min(w, fx + fw + mx); y2 = min(h, fy + fh + my) crop_w, crop_h = x2 - x1, y2 - y1 if crop_w < 20 or crop_h < 20: continue face_cx_px = (x1 + x2) / 2; face_cy_px = (y1 + y2) / 2 if is_duplicate_px(face_cx_px, face_cy_px): continue crop = rgb[y1:y2, x1:x2] target = max(512, crop_w, crop_h) scale = target / max(crop_w, crop_h) cw_scaled = int(crop_w * scale); ch_scaled = int(crop_h * scale) crop_resized = cv2.resize(crop, (cw_scaled, ch_scaled), interpolation=cv2.INTER_CUBIC) crop_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=crop_resized) crop_result = landmarker.detect(crop_image) if crop_result.face_landmarks: face_lms = crop_result.face_landmarks[0] cx_px2 = x1 + np.mean([lm.x for lm in face_lms]) * crop_w cy_px2 = y1 + np.mean([lm.y for lm in face_lms]) * crop_h if is_duplicate_px(cx_px2, cy_px2): continue drawn_centers_px.append((cx_px2, cy_px2)) draw_eyebrow_landmarks(face_lms, x1, y1, crop_w, crop_h) print(f"[eyebrows] Drew eyebrows for {len(drawn_centers_px)} face(s)") except Exception as e: print(f"[AI] Eyebrow extraction error or library missing: {e}") if eyebrows_layer.any(): dil = getattr(cfg, 'eyebrow_dilate', 1) if dil > 0: kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)) eyebrows_layer = cv2.dilate(eyebrows_layer, kernel, iterations=dil) return eyebrows_layer def make_faceless_lineart(img: Image.Image, cfg: LineartConfig = None) -> Image.Image: cfg = cfg or LineartConfig() alpha_channel: Optional[np.ndarray] = None if img.mode in ("RGBA", "LA") or (img.mode == "P" and "transparency" in img.info): rgba_img = img.convert("RGBA") alpha_np = np.array(rgba_img)[:, :, 3] if np.any(alpha_np < 200): alpha_channel = alpha_np background = Image.new("RGBA", img.size, (255, 255, 255, 255)) rgba = Image.alpha_composite(background, rgba_img) img = rgba.convert("RGB") else: img = img.convert("RGB") img_np = np.array(img) rgb = img_np[:, :, :3] h, w = rgb.shape[:2] # If no alpha was provided, derive a subject mask with rembg for a clean # silhouette and to confine interior lines to the subject. if alpha_channel is None and _SESSION_ISNET is not None: try: cut = remove(Image.fromarray(rgb).convert("RGBA"), session=_SESSION_ISNET, only_mask=True) cut_l = cut.convert("L") if isinstance(cut, Image.Image) else Image.open(io.BytesIO(cut)).convert("L") if cut_l.size != (w, h): cut_l = cut_l.resize((w, h), Image.LANCZOS) alpha_channel = np.array(cut_l) except Exception as e: print(f"[AI] rembg mask for lineart failed: {e}") subject_mask = build_subject_mask(rgb, cfg, alpha_channel) faces = detect_faces(rgb, cfg) print(f"[lineart] Detected {len(faces)} face(s)") silhouette = build_silhouette(subject_mask, cfg) interior = build_interior_edges(rgb, subject_mask, cfg) face_erase = build_face_erase_mask(faces, (h, w), cfg) interior_clean = cv2.bitwise_and(interior, cv2.bitwise_not(face_erase)) final_lines = cv2.add(silhouette, interior_clean) if cfg.detect_eyebrows: eyebrows = draw_eyebrows(rgb, cfg, faces=faces) final_lines = cv2.add(final_lines, eyebrows) border_erase_width = max(cfg.silhouette_line_thickness, cfg.interior_line_thickness) + 2 if border_erase_width > 0: final_lines[0:border_erase_width, :] = 0 final_lines[-border_erase_width:, :] = 0 final_lines[:, 0:border_erase_width] = 0 final_lines[:, -border_erase_width:] = 0 alpha = Image.fromarray(final_lines) transparent = Image.new("RGBA", img.size, (0, 0, 0, 0)) transparent.putalpha(alpha) return transparent def decode_base64_image(image_data: str) -> Image.Image: payload = image_data.split(",", 1)[1] if "," in image_data else image_data return Image.open(io.BytesIO(base64.b64decode(payload))).convert("RGBA") def encode_png_data_url(image: Image.Image) -> str: buf = io.BytesIO() image.save(buf, format="PNG") return f"data:image/png;base64,{base64.b64encode(buf.getvalue()).decode()}" def _has_real_transparency(img: Image.Image) -> bool: rgba = img.convert("RGBA") alpha = rgba.getchannel("A") sample = list(alpha.getdata()) total = 0 visible = 0 for index in range(0, len(sample), 12): total += 1 if sample[index] < 245: visible += 1 return total > 0 and (visible / total) >= 0.03 def remove_background_subject(img: Image.Image) -> Image.Image: try: rgba = img.convert("RGBA") if _has_real_transparency(rgba): return rgba if _SESSION_ISNET is None: return rgba mask = remove(rgba, session=_SESSION_ISNET, only_mask=True) alpha = mask.convert("L") if isinstance(mask, Image.Image) else Image.open(io.BytesIO(mask)).convert("L") if alpha.size != rgba.size: alpha = alpha.resize(rgba.size, Image.LANCZOS) alpha = alpha.filter(ImageFilter.GaussianBlur(radius=0.8)) r, g, b, _a = rgba.split() return Image.merge("RGBA", (r, g, b, alpha)) except Exception as exc: print(f"[AI] Background removal failed: {exc}") return img.convert("RGBA") def make_faceless_cutout(img: Image.Image) -> Image.Image: return remove_background_subject(img) def build_faceless_embroidery_assets(img: Image.Image, cfg: Optional[LineartConfig] = None, **kwargs) -> Tuple[Image.Image, Image.Image]: if cfg is None: cfg = LineartConfig() if "prompt" in kwargs and kwargs["prompt"] is not None: cfg.prompt = kwargs["prompt"] if "negative_prompt" in kwargs and kwargs["negative_prompt"] is not None: cfg.negative_prompt = kwargs["negative_prompt"] lineart = make_faceless_lineart(img, cfg) cutout = make_faceless_cutout(img) return lineart, cutout