Spaces:
Running on Zero
Running on Zero
| 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() | |
| 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 | |
| # AUMENTADO: Força bruta para ignorar detalhes fracos | |
| lineart_threshold: int = 120 | |
| # AUMENTADO: Faca implacável para traços curtos | |
| lineart_min_perimeter: float = 90.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 | |
| # AJUSTE FINO: Remove 45% do topo do oval (o que causava aquele erro bizarro) | |
| face_oval_skip_top_ratio: float = 0.45 | |
| 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) | |
| # Aspirador de Sujeiras | |
| 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 EXTREMA: Elimina pontos isolados e traços curtos | |
| if perimeter < cfg.lineart_min_perimeter or area < 40.0: | |
| continue | |
| cv2.drawContours(filtered, [cnt], -1, 255, cfg.interior_line_thickness) | |
| kernel_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}") | |
| 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 |