Añadir mu-plugins y scripts de feadulta
This commit is contained in:
@@ -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 <src> <dst> [--size 256] [--padding 0.6]
|
||||
python3 face_crop_avatar.py --batch <src_dir> <dst_dir> [--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()
|
||||
Reference in New Issue
Block a user