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() @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 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