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 = 1024 lineart_image_resolution: int = 1024 lineart_coarse: bool = True # EQUILÍBRIO: 80 é forte o suficiente para ignorar sombra, mas não apaga o rosto. lineart_threshold: int = 80 # EQUILÍBRIO: 60 pixels corta fios curtos, preservando linhas estruturais. lineart_min_perimeter: float = 60.0 interior_erode_size: int = 1 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 --- eyebrow_thickness: int = 2 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: h, w = target_hw gray = np.array(pil_lineart.convert("L")) corners = [gray[0, 0], gray[0, -1], gray[-1, 0], gray[-1, -1]] bg = float(np.median(corners)) if bg > 127: strength = 255 - gray else: strength = gray 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: 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)) _, edges = cv2.threshold(strength, cfg.lineart_threshold, 255, cv2.THRESH_BINARY) 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) # --- O "ASPIRADOR DE PÓ" MATEMÁTICO --- # Remove pontos solitários grossos antes da medição kernel_clean = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)) edges = cv2.morphologyEx(edges, cv2.MORPH_OPEN, kernel_clean, iterations=1) 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) area = cv2.contourArea(cnt) # Barreira dupla: Remove traços menores que 60px E bolinhas grossas. if perimeter < cfg.lineart_min_perimeter or (perimeter < 150 and area < 10.0): continue cv2.drawContours(filtered, [cnt], -1, 255, cfg.interior_line_thickness) # Suavização final para cantos realistas de linha de costura kernel_dilate = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)) filtered = cv2.dilate(filtered, kernel_dilate, iterations=1) filtered = cv2.GaussianBlur(filtered, (3, 3), 0) # CORREÇÃO: cv2.threshold retorna uma tupla (_, imagem_resultante) _, 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}") 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, 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] 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): 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) 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: if oval_skip_top > 0: y_min = oval_np[:, 1].min() y_max = oval_np[:, 1].max() cutoff = y_min + (y_max - y_min) * oval_skip_top kept = oval_np[oval_np[:, 1] >= cutoff] if len(kept) >= 4: arc = kept.reshape((-1, 1, 2)) 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 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