Fceless / faceless_processing.py
vitor.fran
Improve faceless lineart detail for Space
f127edd
Raw History Blame Contribute Delete
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()
@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