From d21e7f1e12ce29fee4e9fdf0ac4ed21266f76785 Mon Sep 17 00:00:00 2001 From: rafa Date: Mon, 25 May 2026 15:09:01 -0400 Subject: [PATCH] script(avatars): face-crop con OpenCV + regen thumbnails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-procesa los 724 avatares col_*.png (imágenes horizontales con foto+texto) recortándolos cuadrados centrados en la cara antes de mostrarlos como avatar circular. Resuelve la queja del #39 sobre las fotos "stretcheadas". - face_crop_avatar.py: Haar cascade frontal+profile, padding 0.6×, fallback inteligente para imágenes horizontales (recorta lado izquierdo), reduce side cuando padding no cabe (no invade lado del texto) - regen_avatars.php: borra thumbnails viejos + regenera con la nueva imagen para todos los attachments de uploads/autores/joomla/ Resultado: 100 caras detectadas + 624 fallbacks (mayoría placeholders sin foto real) + 0 fail. Attachment IDs preservados. Co-Authored-By: Claude Opus 4.7 --- scripts/face_crop_avatar.py | 142 ++++++++++++++++++++++++++++++++++++ scripts/regen_avatars.php | 40 ++++++++++ 2 files changed, 182 insertions(+) create mode 100644 scripts/face_crop_avatar.py create mode 100644 scripts/regen_avatars.php diff --git a/scripts/face_crop_avatar.py b/scripts/face_crop_avatar.py new file mode 100644 index 0000000..456ebd2 --- /dev/null +++ b/scripts/face_crop_avatar.py @@ -0,0 +1,142 @@ +#!/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() diff --git a/scripts/regen_avatars.php b/scripts/regen_avatars.php new file mode 100644 index 0000000..32b9827 --- /dev/null +++ b/scripts/regen_avatars.php @@ -0,0 +1,40 @@ +get_results( + "SELECT ID FROM {$wpdb->posts} + WHERE post_type='attachment' + AND guid LIKE '%/autores/joomla/%'" +); +echo 'Attachments: ' . count($rows) . PHP_EOL; + +$uploads_basedir = wp_upload_dir()['basedir']; +$ok = 0; $fail = 0; +foreach ($rows as $r) { + $aid = (int) $r->ID; + $file = get_attached_file($aid); + if (!$file || !file_exists($file)) { $fail++; continue; } + + // Borrar thumbnails viejos del attachment (todas las variantes -WxH) + $old_meta = wp_get_attachment_metadata($aid); + if (!empty($old_meta['sizes'])) { + $dir = dirname($file); + foreach ($old_meta['sizes'] as $s) { + $thumb = $dir . '/' . $s['file']; + if (file_exists($thumb)) @unlink($thumb); + } + } + // Regenerar + $meta = wp_generate_attachment_metadata($aid, $file); + if ($meta) { + wp_update_attachment_metadata($aid, $meta); + $ok++; + } else { + $fail++; + } +} +echo "OK: $ok, FAIL: $fail" . PHP_EOL;