Spaces:
Sleeping
Sleeping
Download faceless_processing.py from vitordigitizing/Fceless: direct link, hf CLI and curl.
- Browser
- Download file 51.9 kB
-
https://huggingface.co/spaces/vitordigitizing/Fceless/resolve/main/faceless_processing.py
- Command line
-
hf download hf://spaces/vitordigitizing/Fceless/faceless_processing.py
-
curl -L -o faceless_processing.py https://huggingface.co/spaces/vitordigitizing/Fceless/resolve/main/faceless_processing.py
51.9 kB
| 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/haarcape_frontalface_default.xml', | |
| 'haarcascade_frontalface_alt2.xml': 'https://raw.githubusercontent.com/opencv/opencv/master/data/haarcascades/haarcascade_frontalface_alt2.xml', | |
| 'haarcascade_profileface.xml': 'https://raw.githubusercontent.com/opencv/opencv/master/data/haarcascades/haarcascade_profileface.xml' | |
| } | |
| # Note: Fix frontalface_default URL typo if raw OpenCV has it | |
| xml_urls['haarcascade_frontalface_default.xml'] = 'https://raw.githubusercontent.com/opencv/opencv/master/data/haarcascades/haarcascade_frontalface_default.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 | |
| is_cartoon: bool = False | |
| # 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 | |
| # Silhouette outline | |
| silhouette_line_thickness: int = 4 | |
| # Área mínima de silhueta. Valor menor preserva crianças e figuras pequenas. | |
| min_silhouette_area: float = 90.0 | |
| # --- 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 | |
| # Lower threshold keeps subtle clothing/hair contours that were previously | |
| # discarded before they could be converted into embroidery lines. | |
| lineart_threshold: int = 88 | |
| # REDUZIDO: Permite traços de corpos menores (crianças em grupos) | |
| lineart_min_perimeter: float = 28.0 | |
| lineart_min_area: float = 8.0 | |
| lineart_morph_open: bool = False | |
| force_canny: bool = False | |
| interior_erode_size: int = 1 | |
| interior_line_thickness: int = 4 | |
| # Extra dilation is deliberately opt-in. The old unconditional dilation | |
| # made independent contours merge into thick blobs at embroidery scale. | |
| stroke_dilate_iterations: int = 0 | |
| # ---- Canny fallback parametros ---- | |
| canny_low: int = 50 | |
| canny_high: int = 105 | |
| bilateral_d: int = 11 | |
| bilateral_sigma_color: int = 110 | |
| bilateral_sigma_space: int = 110 | |
| gaussian_ksize: int = 11 | |
| min_contour_perimeter: float = 45.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 = 5 | |
| face_min_size: Tuple[int, int] = (20, 20) | |
| face_max_y_ratio: float = 0.65 | |
| face_nms_iou_thresh: float = 0.55 | |
| face_max_count: int = 6 | |
| # Face erase ellipse | |
| erase_faces: bool = True | |
| erase_borders: bool = True | |
| face_cx_ratio: float = 0.50 | |
| face_cy_ratio: float = 0.50 | |
| face_rx_ratio: float = 0.44 | |
| face_ry_ratio: float = 0.46 | |
| 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 = 3 | |
| # AJUSTE FINO: Remove 50% do topo do oval (desenha apenas a mandíbula inferior) | |
| face_oval_skip_top_ratio: float = 0.50 | |
| # --- Parâmetros de limpeza e bordabilidade --- | |
| min_line_distance: int = 8 | |
| line_overlap_threshold: float = 0.35 | |
| custom_tuned_for_embroidery: bool = False | |
| # --- Parâmetros de confiança de detecção de face --- | |
| min_face_detection_confidence: float = 0.45 | |
| min_face_presence_confidence: float = 0.45 | |
| min_tracking_confidence: 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, w = rgb.shape[:2] | |
| faces = [] | |
| # 1. MediaPipe face detection on global image (finds large/medium faces) | |
| mediapipe_faces = [] | |
| 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") | |
| model_path = os.path.join(model_dir, "face_landmarker.task") | |
| if os.path.exists(model_path): | |
| num_faces = getattr(cfg, 'mediapipe_num_faces', 6) | |
| min_conf = getattr(cfg, 'min_face_detection_confidence', 0.45) | |
| with _LANDMARKER_LOCK: | |
| landmarker = get_landmarker(model_path, num_faces=num_faces, min_detection_confidence=min_conf) | |
| mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=rgb) | |
| mp_result = landmarker.detect(mp_image) | |
| if mp_result.face_landmarks: | |
| for face_lms in mp_result.face_landmarks: | |
| xs = [lm.x * w for lm in face_lms] | |
| ys = [lm.y * h for lm in face_lms] | |
| min_x, max_x = min(xs), max(xs) | |
| min_y, max_y = min(ys), max(ys) | |
| bw = max_x - min_x | |
| bh = max_y - min_y | |
| pad_x = bw * 0.15 | |
| pad_y = bh * 0.15 | |
| fx = int(max(0, min_x - pad_x)) | |
| fy = int(max(0, min_y - pad_y)) | |
| fw = int(min(w - fx, bw + 2 * pad_x)) | |
| fh = int(min(h - fy, bh + 2 * pad_y)) | |
| mediapipe_faces.append([fx, fy, fw, fh]) | |
| except Exception as mp_err: | |
| print(f"[AI] MediaPipe global face detection failed: {mp_err}") | |
| # Add globally detected faces to faces list | |
| faces.extend(mediapipe_faces) | |
| # 2. Haar Cascades (frontal and profile) | |
| # Haar Cascades find candidates (especially small/blurry faces in groups) | |
| haar_candidates = [] | |
| try: | |
| gray = cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY) | |
| clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) | |
| gray_eq = clahe.apply(gray) | |
| 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') | |
| path_profile = os.path.join(cascade_dir, 'haarcascade_profileface.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 os.path.exists(path_profile): | |
| classifiers.append(cv2.CascadeClassifier(path_profile)) | |
| 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'), | |
| cv2.CascadeClassifier(cv2.data.haarcascades + 'haarcascade_profileface.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 detected is not None and len(detected) > 0: | |
| haar_candidates.extend(detected.tolist()) | |
| except Exception as e: | |
| print(f"[AI] Haar Cascade face detection failed: {e}") | |
| # 3. Crop-and-Verify Haar Candidates using MediaPipe | |
| # Only verify candidates if MediaPipe is available | |
| verified_haar_faces = [] | |
| if haar_candidates: | |
| # Deduplicate Haar candidates first using NMS | |
| haar_candidates = _nms_faces(haar_candidates, cfg.face_nms_iou_thresh) | |
| 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") | |
| model_path = os.path.join(model_dir, "face_landmarker.task") | |
| if os.path.exists(model_path): | |
| min_conf = getattr(cfg, 'min_face_detection_confidence', 0.45) | |
| # Use a slightly lower threshold for crop verification since we cropped precisely around the face box | |
| crop_min_conf = max(0.35, min_conf - 0.10) | |
| with _LANDMARKER_LOCK: | |
| landmarker = get_landmarker(model_path, num_faces=1, min_detection_confidence=crop_min_conf) | |
| for (fx, fy, fw, fh) in haar_candidates: | |
| # Check if this Haar box overlaps significantly with any globally detected MediaPipe face | |
| already_detected = False | |
| for (mx, my, mw, mh) in mediapipe_faces: | |
| ix1 = max(fx, mx) | |
| iy1 = max(fy, my) | |
| ix2 = min(fx+fw, mx+mw) | |
| iy2 = min(fy+fh, my+mh) | |
| iw = max(0, ix2 - ix1) | |
| ih = max(0, iy2 - iy1) | |
| inter = iw * ih | |
| area_haar = fw * fh | |
| if area_haar > 0 and (inter / area_haar) > 0.50: | |
| already_detected = True | |
| break | |
| if already_detected: | |
| continue | |
| # Crop with 35% padding | |
| pad_w = int(fw * 0.35) | |
| pad_h = int(fh * 0.35) | |
| x1 = max(0, fx - pad_w) | |
| y1 = max(0, fy - pad_h) | |
| x2 = min(w, fx + fw + pad_w) | |
| y2 = min(h, fy + fh + pad_h) | |
| crop_w, crop_h = x2 - x1, y2 - y1 | |
| if crop_w < 20 or crop_h < 20: | |
| continue | |
| crop = rgb[y1:y2, x1:x2] | |
| # Resize crop to 256x256 to help MediaPipe detect small faces easily | |
| crop_resized = cv2.resize(crop, (256, 256), 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: | |
| # It is a verified human face! | |
| verified_haar_faces.append([fx, fy, fw, fh]) | |
| print(f"[lineart] Verified face candidate at [{fx}, {fy}, {fw}, {fh}] using crop-based MediaPipe.") | |
| else: | |
| # If model_path doesn't exist, we fall back to trusting Haar Cascades directly | |
| verified_haar_faces = haar_candidates | |
| except Exception as verify_err: | |
| print(f"[AI] Crop verification failed, falling back to trusting Haar candidates: {verify_err}") | |
| verified_haar_faces = haar_candidates | |
| faces.extend(verified_haar_faces) | |
| # 3. Filter and Deduplicate | |
| 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 | |
| # Preencher buracos (ex: olhos brancos que foram considerados fundo) | |
| contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) | |
| cv2.drawContours(mask, contours, -1, 255, -1) | |
| 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() | |
| h, w = mask.shape[:2] | |
| # Dynamic proportional smoothing based on image size to eliminate jaggies completely | |
| k = int(max(w, h) * 0.035) | |
| if k % 2 == 0: | |
| k += 1 | |
| k = max(25, k) | |
| 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.0012 * 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(rgb: np.ndarray, faces: list, cfg: LineartConfig) -> np.ndarray: | |
| h, w = rgb.shape[:2] | |
| mask = np.zeros((h, w), dtype=np.uint8) | |
| if not getattr(cfg, 'erase_faces', True): | |
| return mask | |
| if not faces: | |
| return mask | |
| # 1. Exact polygon face erasure using MediaPipe FACE_OVAL landmarks (100% covers mouth/smile/eyes/nose exactly) | |
| 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") | |
| model_path = os.path.join(model_dir, "face_landmarker.task") | |
| if os.path.exists(model_path): | |
| num_faces = getattr(cfg, 'mediapipe_num_faces', 6) | |
| 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] | |
| with _LANDMARKER_LOCK: | |
| landmarker = get_landmarker(model_path, num_faces=num_faces) | |
| mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=rgb) | |
| mp_result = landmarker.detect(mp_image) | |
| if mp_result.face_landmarks: | |
| for face_lms in mp_result.face_landmarks: | |
| pts = [] | |
| for idx in FACE_OVAL: | |
| lm = face_lms[idx] | |
| px = int(lm.x * w) | |
| py = int(lm.y * h) | |
| pts.append([px, py]) | |
| # Scale down erase mask to 85% to protect the outer jawline/chin | |
| cx_f = np.mean([pt[0] for pt in pts]) | |
| cy_f = np.mean([pt[1] for pt in pts]) | |
| scaled_pts = [[int(cx_f + (pt[0] - cx_f) * 0.85), int(cy_f + (pt[1] - cy_f) * 0.85)] for pt in pts] | |
| pts_np = np.array(scaled_pts, np.int32).reshape((-1, 1, 2)) | |
| cv2.fillPoly(mask, [pts_np], 255) | |
| # Crop-based erasure for faces missed by global detection | |
| 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 | |
| # Check if this face is already masked | |
| cx_bound = min(max(0, int(face_cx_px)), w - 1) | |
| cy_bound = min(max(0, int(face_cy_px)), h - 1) | |
| roi = mask[max(0, cy_bound-4):min(h, cy_bound+4), max(0, cx_bound-4):min(w, cx_bound+4)] | |
| if roi.size > 0 and np.sum(roi) > 0: | |
| continue # Already masked | |
| 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) | |
| with _LANDMARKER_LOCK: | |
| crop_result = landmarker.detect(crop_image) | |
| if crop_result.face_landmarks: | |
| face_lms = crop_result.face_landmarks[0] | |
| pts = [] | |
| for idx in FACE_OVAL: | |
| lm = face_lms[idx] | |
| px = int(x1 + lm.x * crop_w) | |
| py = int(y1 + lm.y * crop_h) | |
| pts.append([px, py]) | |
| # Scale down erase mask to 85% to protect the outer jawline/chin | |
| cx_f = np.mean([pt[0] for pt in pts]) | |
| cy_f = np.mean([pt[1] for pt in pts]) | |
| scaled_pts = [[int(cx_f + (pt[0] - cx_f) * 0.85), int(cy_f + (pt[1] - cy_f) * 0.85)] for pt in pts] | |
| pts_np = np.array(scaled_pts, np.int32).reshape((-1, 1, 2)) | |
| cv2.fillPoly(mask, [pts_np], 255) | |
| except Exception as mp_err: | |
| print(f"[AI] MediaPipe face contour polygon erase failed: {mp_err}") | |
| # 2. Bounding-box Ellipse Fallback for faces not covered by MediaPipe | |
| for (fx, fy, fw, fh) in faces: | |
| cx = int(fx + fw * cfg.face_cx_ratio) | |
| cy = int(fy + fh * cfg.face_cy_ratio) | |
| # Ensure coordinates are within image boundaries | |
| cx_bound = min(max(0, cx), w - 1) | |
| cy_bound = min(max(0, cy), h - 1) | |
| # Check if the center of this face is already masked | |
| # We check a small 9x9 region around the center | |
| roi = mask[max(0, cy_bound-4):min(h, cy_bound+4), max(0, cx_bound-4):min(w, cx_bound+4)] | |
| if roi.size == 0 or np.sum(roi) == 0: | |
| 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, lineType=cv2.LINE_AA) | |
| 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 _filter_dense_contours(contours: list, min_dist_px: int, overlap_threshold: float, min_perimeter: float, thickness: int, shape: Tuple[int, int], min_area: float = 0.0) -> np.ndarray: | |
| """ | |
| Filters contours to ensure that no two lines are too close to each other, | |
| which makes the lineart highly suitable for embroidery (prevents dense/overlapping stitches). | |
| """ | |
| sorted_cnts = sorted(contours, key=lambda c: cv2.arcLength(c, False), reverse=True) | |
| filtered_mask = np.zeros(shape, dtype=np.uint8) | |
| dilated_mask = np.zeros(shape, dtype=np.uint8) | |
| if min_dist_px > 0: | |
| kernel_dist = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (min_dist_px * 2 + 1, min_dist_px * 2 + 1)) | |
| else: | |
| kernel_dist = None | |
| for cnt in sorted_cnts: | |
| perimeter = cv2.arcLength(cnt, False) | |
| # contourArea requires at least 3 points | |
| area = cv2.contourArea(cnt) if len(cnt) >= 3 else 0.0 | |
| if perimeter < min_perimeter: | |
| continue | |
| if min_area > 0 and area < min_area: | |
| continue | |
| cnt_mask = np.zeros(shape, dtype=np.uint8) | |
| cv2.drawContours(cnt_mask, [cnt], -1, 255, thickness, lineType=cv2.LINE_AA) | |
| if kernel_dist is not None and np.any(dilated_mask > 0): | |
| intersection = cv2.bitwise_and(cnt_mask, dilated_mask) | |
| num_intersect = cv2.countNonZero(intersection) | |
| num_total = cv2.countNonZero(cnt_mask) | |
| if num_total > 0 and (num_intersect / num_total) > overlap_threshold: | |
| # Too close to existing lines | |
| continue | |
| cv2.drawContours(filtered_mask, [cnt], -1, 255, thickness, lineType=cv2.LINE_AA) | |
| if kernel_dist is not None: | |
| cnt_draw = np.zeros(shape, dtype=np.uint8) | |
| cv2.drawContours(cnt_draw, [cnt], -1, 255, thickness, lineType=cv2.LINE_AA) | |
| cnt_dilated = cv2.dilate(cnt_draw, kernel_dist, iterations=1) | |
| dilated_mask = cv2.bitwise_or(dilated_mask, cnt_dilated) | |
| return filtered_mask | |
| 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 and not getattr(cfg, 'force_canny', False): | |
| 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) | |
| if getattr(cfg, 'lineart_morph_open', True): | |
| # 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) | |
| min_area = getattr(cfg, 'lineart_min_area', 40.0) | |
| min_dist = getattr(cfg, 'min_line_distance', 8) | |
| overlap_thresh = getattr(cfg, 'line_overlap_threshold', 0.35) | |
| filtered = _filter_dense_contours( | |
| contours=contours, | |
| min_dist_px=min_dist, | |
| overlap_threshold=overlap_thresh, | |
| min_perimeter=cfg.lineart_min_perimeter, | |
| thickness=cfg.interior_line_thickness, | |
| shape=edges.shape, | |
| min_area=min_area | |
| ) | |
| dilate_iterations = max(0, int(getattr(cfg, 'stroke_dilate_iterations', 0))) | |
| if dilate_iterations: | |
| kernel_dilate = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)) | |
| filtered = cv2.dilate(filtered, kernel_dilate, iterations=dilate_iterations) | |
| 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 _skeletonize(img: np.ndarray) -> np.ndarray: | |
| """ | |
| Applies morphological skeletonization to reduce binary lines to 1-pixel wide centerlines. | |
| """ | |
| size = np.size(img) | |
| skel = np.zeros(img.shape, np.uint8) | |
| element = cv2.getStructuringElement(cv2.MORPH_CROSS, (3, 3)) | |
| img_copy = img.copy() | |
| for _ in range(120): # safety limit | |
| eroded = cv2.erode(img_copy, element) | |
| temp = cv2.dilate(eroded, element) | |
| temp = cv2.subtract(img_copy, temp) | |
| skel = cv2.bitwise_or(skel, temp) | |
| img_copy = eroded | |
| if cv2.countNonZero(img_copy) == 0: | |
| break | |
| return skel | |
| def _build_interior_edges_canny(rgb: np.ndarray, subject_mask: np.ndarray, cfg: LineartConfig) -> np.ndarray: | |
| h, w = rgb.shape[:2] | |
| max_dim = max(w, h) | |
| scale = max_dim / 512.0 | |
| gray = cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY) | |
| if getattr(cfg, 'is_cartoon', False): | |
| # Para desenhos/cartoons, aplicamos um filtro bilateral muito mais forte para simplificar texturas (como pelos de animais ou texturas de pele) | |
| d_bilateral = int(15 * scale) | |
| if d_bilateral % 2 == 0: | |
| d_bilateral += 1 | |
| d_bilateral = max(9, d_bilateral) | |
| blurred = cv2.bilateralFilter(gray, d_bilateral, 150, 150) | |
| k_gaussian = int(9 * scale) | |
| if k_gaussian % 2 == 0: | |
| k_gaussian += 1 | |
| k_gaussian = max(5, k_gaussian) | |
| blurred = cv2.GaussianBlur(blurred, (k_gaussian, k_gaussian), 0) | |
| else: | |
| 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) | |
| if getattr(cfg, 'is_cartoon', False): | |
| # 1. Binarização adaptativa inteligente para extrair traços cheios (evita contorno duplo!) | |
| # Usamos uma janela adaptativa maior para capturar linhas de estrutura e ignorar texturas locais | |
| block_size = int(65 * scale) | |
| if block_size % 2 == 0: | |
| block_size += 1 | |
| block_size = max(25, block_size) | |
| corners = [blurred[0, 0], blurred[0, -1], blurred[-1, 0], blurred[-1, -1]] | |
| bg = float(np.median(corners)) | |
| if bg > 127: # Fundo claro, traços escuros | |
| thresh = cv2.adaptiveThreshold( | |
| blurred, 255, | |
| cv2.ADAPTIVE_THRESH_GAUSSIAN_C, | |
| cv2.THRESH_BINARY_INV, | |
| block_size, 8 | |
| ) | |
| else: # Fundo escuro, traços claros | |
| thresh = cv2.adaptiveThreshold( | |
| blurred, 255, | |
| cv2.ADAPTIVE_THRESH_GAUSSIAN_C, | |
| cv2.THRESH_BINARY, | |
| block_size, 8 | |
| ) | |
| # 2. Canny complementar para não perder pequenos detalhes de alto contraste (ex: olhos) | |
| canny_edges = cv2.Canny(blurred, cfg.canny_low, cfg.canny_high) | |
| # Dilata Canny levemente para fundir com os traços do limiar adaptativo | |
| kernel_c = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)) | |
| canny_dilated = cv2.dilate(canny_edges, kernel_c, iterations=1) | |
| # Combina ambos | |
| combined = cv2.bitwise_or(thresh, canny_dilated) | |
| # 3. Esqueletização para converter todos os traços cheios em linhas de 1 pixel centralizadas | |
| edges = _skeletonize(combined) | |
| else: | |
| 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) | |
| # Determinamos o perímetro mínimo de forma dinâmica para descartar pequenos riscos (ex: pelos) | |
| min_perimeter = cfg.min_contour_perimeter | |
| if getattr(cfg, 'is_cartoon', False): | |
| min_perimeter = max(min_perimeter, 45.0 * scale) | |
| smoothed_contours = [] | |
| for cnt in contours: | |
| perimeter = cv2.arcLength(cnt, False) | |
| if perimeter < min_perimeter: | |
| continue | |
| eps = 0.001 * perimeter if getattr(cfg, 'is_cartoon', False) else 0.0012 * perimeter | |
| smooth = cv2.approxPolyDP(cnt, eps, False) | |
| smoothed_contours.append(smooth) | |
| min_dist = getattr(cfg, 'min_line_distance', 8) | |
| overlap_thresh = getattr(cfg, 'line_overlap_threshold', 0.35) | |
| filtered = _filter_dense_contours( | |
| contours=smoothed_contours, | |
| min_dist_px=min_dist, | |
| overlap_threshold=overlap_thresh, | |
| min_perimeter=min_perimeter, | |
| thickness=cfg.interior_line_thickness, | |
| shape=edges.shape | |
| ) | |
| dilate_iterations = max(0, int(getattr(cfg, 'stroke_dilate_iterations', 0))) | |
| if dilate_iterations: | |
| kernel_dilate = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)) | |
| filtered = cv2.dilate(filtered, kernel_dilate, iterations=dilate_iterations) | |
| return filtered | |
| _LANDMARKER_LOCK = threading.Lock() | |
| _LANDMARKER_CACHE = None | |
| _LANDMARKER_NUM_FACES = None | |
| _LANDMARKER_CONFIDENCE = None | |
| def get_landmarker(model_path, num_faces: int = 6, min_detection_confidence: float = 0.68): | |
| global _LANDMARKER_CACHE, _LANDMARKER_NUM_FACES, _LANDMARKER_CONFIDENCE | |
| if _LANDMARKER_CACHE is None or _LANDMARKER_NUM_FACES != num_faces or _LANDMARKER_CONFIDENCE != min_detection_confidence: | |
| 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=min_detection_confidence, | |
| min_face_presence_confidence=min_detection_confidence, | |
| min_tracking_confidence=min_detection_confidence, | |
| ) | |
| _LANDMARKER_CACHE = vision.FaceLandmarker.create_from_options(options) | |
| _LANDMARKER_NUM_FACES = num_faces | |
| _LANDMARKER_CONFIDENCE = min_detection_confidence | |
| 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 = getattr(cfg, 'draw_face_oval', False) | |
| 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, face_size: float = 0.0) -> bool: | |
| limit = face_size * 0.35 if face_size > 0 else min(w, h) * 0.02 | |
| limit = max(15.0, limit) | |
| for (pcx, pcy) in drawn_centers_px: | |
| if np.hypot(cx_px - pcx, cy_px - pcy) < limit: | |
| 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 | |
| xs = [lm.x * w for lm in face_lms] | |
| ys = [lm.y * h for lm in face_lms] | |
| fsize = max(max(xs) - min(xs), max(ys) - min(ys)) | |
| if not is_duplicate_px(cx_px, cy_px, fsize): | |
| 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 | |
| fsize = max(fw, fh) | |
| if is_duplicate_px(face_cx_px, face_cy_px, fsize): | |
| 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 | |
| xs2 = [lm.x * crop_w for lm in face_lms] | |
| ys2 = [lm.y * crop_h for lm in face_lms] | |
| fsize2 = max(max(xs2) - min(xs2), max(ys2) - min(ys2)) | |
| if is_duplicate_px(cx_px2, cy_px2, fsize2): | |
| 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}") | |
| faces = detect_faces(rgb, cfg) | |
| print(f"[lineart] Detected {len(faces)} face(s)") | |
| # Se NÃO detectou rosto, entra em modo de contingência ("cartoon-clean" automático) | |
| is_cartoon_fallback = getattr(cfg, 'is_cartoon_fallback', False) or (len(faces) == 0) | |
| if is_cartoon_fallback and not getattr(cfg, 'custom_tuned_for_embroidery', False): | |
| print("[lineart] No face detected or cartoon fallback active. Tuning parameters for clean structured lineart.") | |
| cfg.erase_faces = False | |
| cfg.detect_eyebrows = False | |
| cfg.erase_borders = False | |
| # Parâmetros equilibrados para traços nítidos e bordáveis | |
| cfg.lineart_threshold = 95 | |
| cfg.lineart_min_perimeter = 45.0 | |
| cfg.lineart_min_area = 20.0 | |
| cfg.lineart_morph_open = True | |
| cfg.canny_low = 50 | |
| cfg.canny_high = 110 | |
| cfg.min_contour_perimeter = 35.0 | |
| # Suavização para remover pelos sem perder formas principais | |
| cfg.bilateral_d = 7 | |
| cfg.bilateral_sigma_color = 90 | |
| cfg.bilateral_sigma_space = 90 | |
| cfg.gaussian_ksize = 5 | |
| # Filtro de proximidade entre linhas | |
| cfg.min_line_distance = 8 | |
| cfg.line_overlap_threshold = 0.35 | |
| cfg.custom_tuned_for_embroidery = True | |
| elif len(faces) > 0 and not getattr(cfg, 'custom_tuned_for_embroidery', False): | |
| # Retrato de humanos/família: liberamos mais detalhes de roupas, braços e dobras de forma limpa! | |
| print("[lineart] Human faces detected. Adjusting parameters to preserve clothing and body details.") | |
| cfg.lineart_threshold = 80 # Permite mais traços (antes era 90) para não ficar falhado | |
| cfg.lineart_min_perimeter = 25.0 # Menor: preserva detalhes finos/traços curtos (antes era 40) | |
| cfg.lineart_min_area = 8.0 # Menor: preserva estruturas menores (antes era 14) | |
| cfg.lineart_morph_open = False # Desativado: MORPH_OPEN fragmenta e quebra linhas finas (antes era True) | |
| # Canny parameters (caso de fallback) | |
| cfg.canny_low = 35 | |
| cfg.canny_high = 85 | |
| cfg.min_contour_perimeter = 20.0 | |
| # Proximidade menos agressiva para evitar deletar traços que se aproximam da silhueta | |
| cfg.min_line_distance = 5 # Permite traços um pouco mais próximos (antes era 7) | |
| cfg.line_overlap_threshold = 0.65 # Permite maior proximidade sem descartar o traço inteiro (antes era 0.35) | |
| cfg.custom_tuned_for_embroidery = True | |
| subject_mask = build_subject_mask(rgb, cfg, alpha_channel) | |
| silhouette = build_silhouette(subject_mask, cfg) | |
| interior = build_interior_edges(rgb, subject_mask, cfg) | |
| face_erase = build_face_erase_mask(rgb, faces, 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) | |
| erase_borders = getattr(cfg, 'erase_borders', True) | |
| if erase_borders: | |
| border_erase_width = max(cfg.silhouette_line_thickness, cfg.interior_line_thickness) + 2 | |
| if border_erase_width > 0: | |
| # Keep top border erase very small (e.g. 1 pixel) to avoid cutting the top of the head | |
| final_lines[0:1, :] = 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 | |