Files
fn_registry/python/functions/ml/crop_to_content.py
T

163 lines
5.9 KiB
Python

"""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.02,
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 <src> <dst> [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}")