#!/usr/bin/env python3 """ Recorta avatares cuadrados centrados en la cara, usando Haar cascade de OpenCV. Uso: python3 face_crop_avatar.py [--size 256] [--padding 0.6] python3 face_crop_avatar.py --batch [--size 256] Strategy: - Detecta caras frontales (haarcascade_frontalface_default). - Si encuentra >=1: coge la mayor, expande con padding (factor del lado de la cara) y recorta cuadrado. - Si encuentra 0: fallback a crop cuadrado centrado en el TERCIO SUPERIOR de la imagen (donde suele estar la cabeza en fotos verticales). - Redimensiona el cuadrado a `size x size`. Mantiene aspecto natural — NO estira. """ import argparse, os, sys import cv2 DEFAULT_SIZE = 256 DEFAULT_PADDING = 0.6 # factor del lado de la cara para añadir alrededor cascade_path = cv2.data.haarcascades + 'haarcascade_frontalface_default.xml' profile_cascade_path = cv2.data.haarcascades + 'haarcascade_profileface.xml' _face_cascade = cv2.CascadeClassifier(cascade_path) _profile_cascade = cv2.CascadeClassifier(profile_cascade_path) def detect_face(gray): """Devuelve (x, y, w, h) de la cara más grande, o None.""" for cascade in (_face_cascade, _profile_cascade): faces = cascade.detectMultiScale( gray, scaleFactor=1.1, minNeighbors=5, minSize=(30, 30) ) if len(faces): # más grande return max(faces, key=lambda r: r[2] * r[3]) # también lateral en flip if cascade is _profile_cascade: flipped = cv2.flip(gray, 1) faces2 = cascade.detectMultiScale(flipped, 1.1, 5, minSize=(30, 30)) if len(faces2): x, y, w, h = max(faces2, key=lambda r: r[2] * r[3]) return (gray.shape[1] - x - w, y, w, h) return None def square_crop_box(face, img_w, img_h, padding): """Caja cuadrada centrada en la cara. Si el padding no cabe sin invadir lados opuestos (típicamente texto), se REDUCE el side antes que extender. """ x, y, w, h = face cx, cy = x + w / 2, y + h / 2 ideal = max(w, h) * (1 + 2 * padding) # side máximo manteniendo cara centrada y dentro de la imagen max_x = 2 * min(cx, img_w - cx) max_y = 2 * min(cy, img_h - cy) side = min(ideal, max_x, max_y) half = side / 2 x1, y1 = int(cx - half), int(cy - half) x2, y2 = int(cx + half), int(cy + half) return x1, y1, x2, y2 def fallback_box(img_w, img_h): """Sin cara detectada. Heurística por aspect ratio: - Horizontal (w > h*1.3): cuadrado a la IZQUIERDA (col_* suelen tener foto a la izquierda y texto a la derecha). - Vertical o cuadrado: cuadrado anclado al tercio superior, centrado en x. """ if img_w > img_h * 1.3: side = img_h return 0, 0, side, side side = min(img_w, img_h) cx = img_w / 2 x1 = max(0, int(cx - side / 2)) return x1, 0, x1 + side, side def process(src_path, dst_path, size=DEFAULT_SIZE, padding=DEFAULT_PADDING): img = cv2.imread(src_path, cv2.IMREAD_UNCHANGED) if img is None: return False, 'imread fail' # convertir alpha → blanco si hace falta if img.ndim == 3 and img.shape[2] == 4: # composite sobre blanco bgr = img[:, :, :3].copy() alpha = img[:, :, 3] / 255.0 white = (1 - alpha[:, :, None]) * 255 img = (bgr * alpha[:, :, None] + white).astype('uint8') elif img.ndim == 2: img = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR) h, w = img.shape[:2] gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) face = detect_face(gray) if face is not None: x1, y1, x2, y2 = square_crop_box(face, w, h, padding) used = 'face' else: x1, y1, x2, y2 = fallback_box(w, h) used = 'fallback' crop = img[y1:y2, x1:x2] resized = cv2.resize(crop, (size, size), interpolation=cv2.INTER_AREA) # asegurar JPEG-safe (sin alpha) if resized.ndim == 3 and resized.shape[2] == 4: resized = cv2.cvtColor(resized, cv2.COLOR_BGRA2BGR) os.makedirs(os.path.dirname(dst_path), exist_ok=True) cv2.imwrite(dst_path, resized, [cv2.IMWRITE_JPEG_QUALITY, 88]) return True, used def main(): ap = argparse.ArgumentParser() ap.add_argument('src') ap.add_argument('dst') ap.add_argument('--size', type=int, default=DEFAULT_SIZE) ap.add_argument('--padding', type=float, default=DEFAULT_PADDING) ap.add_argument('--batch', action='store_true', help='src y dst son directorios') args = ap.parse_args() if args.batch: files = [f for f in os.listdir(args.src) if f.lower().endswith(('.png', '.jpg', '.jpeg', '.webp'))] stats = {'face': 0, 'fallback': 0, 'fail': 0} for fn in files: ok, info = process( os.path.join(args.src, fn), os.path.join(args.dst, os.path.splitext(fn)[0] + '.jpg'), size=args.size, padding=args.padding, ) stats['fail' if not ok else info] += 1 print(f'face: {stats["face"]}, fallback: {stats["fallback"]}, fail: {stats["fail"]}') else: ok, info = process(args.src, args.dst, size=args.size, padding=args.padding) print(f'{"OK" if ok else "FAIL"} {info} → {args.dst}') if __name__ == '__main__': main()