"""crop_to_content — recorta una imagen PIL al bounding box de su contenido y la cuadra. Quita el aire alrededor del sujeto para que llene el frame antes de un downscale a pixel-art: si el sujeto ocupa el 25% del lienzo, al bajar a 64px queda diminuto y tosco (pocos pixeles para el detalle). Esta funcion calcula el bounding box del contenido, recorta a ese bbox, anade un margen relativo y, opcionalmente, rellena a cuadrado sin deformar para que el sujeto llene el frame. Como detecta el contenido: - Si la imagen tiene canal alpha (RGBA / LA / P con transparencia): el bbox es la region con `alpha > alpha_threshold` (lo opaco es el sujeto, lo transparente es fondo). Es el caso tras pasar la imagen por rembg. - Si no tiene alpha (RGB): el bbox es la region que difiere del color de fondo, estimado como la moda de los cuatro pixeles de esquina. Sirve para imagenes con fondo plano sin recortar todavia. Relleno a cuadrado (`square=True`): el lado del lienzo final es `max(w, h) + 2*pad` y el sujeto se centra. El fondo del lienzo es transparente si la imagen tiene alpha, o el color de fondo estimado si es RGB. Asi no se deforma el sujeto. Funcion pura: opera sobre el objeto PIL.Image y devuelve uno nuevo; no toca disco ni red y no muta la imagen de entrada. Si no encuentra contenido (lienzo vacio o todo transparente), devuelve una copia intacta de la entrada — nunca lanza por una imagen sin sujeto (contrato no-throw salvo `img` None). """ from __future__ import annotations from collections import Counter def _as_rgb_tuple(c) -> tuple: """Normaliza un pixel (int de modo L, o tupla RGB/RGBA) a una 3-tupla RGB.""" if isinstance(c, (tuple, list)): return tuple(int(x) for x in c[:3]) return (int(c), int(c), int(c)) def _corner_bg_color(img) -> tuple: """Color de fondo estimado: la moda de los cuatro pixeles de esquina (RGB).""" rgb = img.convert("RGB") w, h = rgb.size corners = [ rgb.getpixel((0, 0)), rgb.getpixel((w - 1, 0)), rgb.getpixel((0, h - 1)), rgb.getpixel((w - 1, h - 1)), ] corners = [_as_rgb_tuple(c) for c in corners] return Counter(corners).most_common(1)[0][0] def _has_alpha(img) -> bool: """True si la imagen lleva transparencia (RGBA, LA o P con transparency).""" return img.mode in ("RGBA", "LA") or (img.mode == "P" and "transparency" in img.info) def _content_bbox(img, alpha_threshold: int, bg_tolerance: int): """Devuelve (l, t, r, b) del contenido o None si no hay. Por alpha si la imagen lo tiene; si no, por diferencia contra el color de fondo de las esquinas con tolerancia `bg_tolerance`. """ from PIL import Image, ImageChops if _has_alpha(img): alpha = img.convert("RGBA").getchannel("A") mask = alpha.point(lambda p: 255 if p > alpha_threshold else 0) return mask.getbbox() rgb = img.convert("RGB") bg = Image.new("RGB", rgb.size, _corner_bg_color(rgb)) diff = ImageChops.difference(rgb, bg).convert("L") mask = diff.point(lambda p: 255 if p > bg_tolerance else 0) return mask.getbbox() def crop_to_content( img, *, pad_ratio: float = 0.06, square: bool = True, alpha_threshold: int = 10, bg_tolerance: int = 16, ): """Recorta una imagen PIL al bbox de su contenido, con margen y cuadrado opcional. Args: img: PIL.Image de entrada (cualquier modo). No se muta. pad_ratio: margen anadido alrededor del sujeto como fraccion del lado mayor del bbox recortado (0.06 = 6%). 0 = sin margen. keyword-only. square: si True rellena a un lienzo cuadrado de lado `max(w,h)+2*pad` con el sujeto centrado (fondo transparente si hay alpha, color de fondo si RGB); si False solo recorta al bbox + margen sin cuadrar. keyword-only. alpha_threshold: umbral de alpha (0..255) para considerar un pixel "contenido" cuando la imagen tiene canal alpha. keyword-only. bg_tolerance: tolerancia (0..255) de diferencia contra el color de fondo de las esquinas para imagenes sin alpha (RGB). keyword-only. Returns: PIL.Image nueva recortada (y cuadrada si square). Si la imagen no tiene contenido detectable (todo transparente o todo del color de fondo), devuelve una copia intacta de la entrada. Raises: ValueError: si img es None. """ from PIL import Image if img is None: raise ValueError("crop_to_content: img es None") bbox = _content_bbox(img, int(alpha_threshold), int(bg_tolerance)) if bbox is None: return img.copy() left, top, right, bottom = bbox cropped = img.crop((left, top, right, bottom)) cw, ch = cropped.size pad = int(round(max(cw, ch) * float(pad_ratio))) has_alpha = _has_alpha(img) if has_alpha: base = cropped.convert("RGBA") bg_fill = (0, 0, 0, 0) mode = "RGBA" else: base = cropped.convert("RGB") bg_fill = _corner_bg_color(img) mode = "RGB" if square: side = max(cw, ch) + 2 * pad canvas = Image.new(mode, (side, side), bg_fill) ox = (side - cw) // 2 oy = (side - ch) // 2 else: if pad <= 0: return base canvas = Image.new(mode, (cw + 2 * pad, ch + 2 * pad), bg_fill) ox = oy = pad if has_alpha: canvas.paste(base, (ox, oy), base) # usa el alpha del sujeto como mascara else: canvas.paste(base, (ox, oy)) return canvas if __name__ == "__main__": import sys from PIL import Image if len(sys.argv) < 3: print("uso: crop_to_content.py [pad_ratio]", file=sys.stderr) sys.exit(2) src, dst = sys.argv[1], sys.argv[2] pr = float(sys.argv[3]) if len(sys.argv) > 3 else 0.06 with Image.open(src) as im: out = crop_to_content(im, pad_ratio=pr) out.save(dst) print(f"ok: {src} -> {dst} {out.size} {out.mode}")