Spaces:
Sleeping
Sleeping
| import io | |
| import base64 | |
| import re | |
| import numpy as np | |
| import cv2 | |
| import easyocr | |
| import torch | |
| import requests | |
| from PIL import Image | |
| from fastapi import FastAPI, File, UploadFile, HTTPException, Form | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from fastapi.responses import Response | |
| from rembg import remove, new_session | |
| from simple_lama_inpainting import SimpleLama | |
| from transformers import AutoImageProcessor, AutoModelForImageClassification | |
| app = FastAPI(title="ZGrafic API") | |
| # Permite que tu página en GitHub Pages (u otro origen) llame a este servidor. | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["*"], | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| async def start_telegram_bot(): | |
| import asyncio | |
| from bot import run_bot | |
| asyncio.create_task(run_bot()) | |
| # BiRefNet es una arquitectura más nueva (2024) que en la práctica | |
| # supera a u2net/isnet en escenas complejas con varios sujetos. Es más | |
| # pesado y lento en CPU, pero vale la pena para fotos difíciles. | |
| session = new_session("birefnet-general") | |
| # Para el endpoint /extract-text: detecta texto (OCR) y reconstruye el | |
| # fondo donde estaba (inpainting), para volverlo editable. | |
| ocr_reader = easyocr.Reader(["es", "en"], gpu=False) | |
| lama = SimpleLama() | |
| TEXT_PADDING = 4 | |
| # Identifica la fuente más parecida entre 48 fuentes estándar (licencia | |
| # MIT, sin restricción de uso comercial). | |
| font_processor = AutoImageProcessor.from_pretrained("gaborcselle/font-identifier") | |
| font_model = AutoModelForImageClassification.from_pretrained("gaborcselle/font-identifier") | |
| font_model.eval() | |
| # El alpha matting es pesado: en imágenes grandes puede quedarse sin | |
| # memoria o tardar demasiado en el CPU gratuito. Por encima de este | |
| # tamaño, lo desactivamos automáticamente aunque el usuario lo pida. | |
| MATTING_MAX_PIXELS = 1_500_000 # ~ 1500x1000 | |
| def resize_if_needed(pil_img, max_side=4000): | |
| w, h = pil_img.size | |
| if max(w, h) > max_side: | |
| ratio = max_side / max(w, h) | |
| pil_img = pil_img.resize((int(w * ratio), int(h * ratio)), Image.LANCZOS) | |
| return pil_img | |
| def recolor_from_original(png_bytes, original_rgb_array): | |
| """rembg deja en negro el color de cualquier píxel que considera | |
| transparente (total o parcialmente), lo que genera parches o motas | |
| negras en zonas de textura difícil. Reconstruye el color SIEMPRE desde | |
| la foto original, y usa el resultado de la IA solo para el canal alfa | |
| (transparencia).""" | |
| img = Image.open(io.BytesIO(png_bytes)).convert("RGBA") | |
| arr = np.array(img) | |
| arr[:, :, 0:3] = original_rgb_array | |
| out = Image.fromarray(arr, "RGBA") | |
| buf = io.BytesIO() | |
| out.save(buf, format="PNG") | |
| return buf.getvalue() | |
| def remove_stray_specks(png_bytes, erode_px=31, safe_margin_px=45, min_core_area_ratio=0.0015): | |
| """Elimina restos de "primer plano" que cuelgan de un mechón/hilo muy | |
| delgado -como un pelo suelto que termina en una mancha-, sin arriesgar | |
| personas reales (incluso si están separadas del resto, como en una | |
| foto grupal). | |
| La clave: erosiona la máscara primero. Cualquier mechón delgado | |
| desaparece por completo con la erosión, mientras que una persona real | |
| (una masa sólida) sobrevive como un núcleo más chico pero presente. | |
| Se arma la "zona segura" a partir de esos núcleos sólidos, y se borra | |
| todo lo que quede afuera -sin importar si técnicamente estaba | |
| "conectado" por un hilo finito en la máscara original.""" | |
| img = Image.open(io.BytesIO(png_bytes)).convert("RGBA") | |
| arr = np.array(img) | |
| alpha = arr[:, :, 3] | |
| fg = (alpha > 60).astype(np.uint8) | |
| erode_kernel = np.ones((erode_px, erode_px), np.uint8) | |
| eroded = cv2.erode(fg, erode_kernel, iterations=1) | |
| num_labels, labels, stats, _ = cv2.connectedComponentsWithStats(eroded, connectivity=8) | |
| if num_labels <= 1: | |
| return png_bytes # no quedó ningún núcleo sólido: no tocar nada, por seguridad | |
| total_area = fg.shape[0] * fg.shape[1] | |
| safe_core = np.zeros_like(fg) | |
| for label in range(1, num_labels): | |
| area = stats[label, cv2.CC_STAT_AREA] | |
| if area >= total_area * min_core_area_ratio: | |
| safe_core[labels == label] = 1 | |
| if not safe_core.any(): | |
| return png_bytes | |
| dilate_kernel = np.ones((safe_margin_px, safe_margin_px), np.uint8) | |
| safe_zone = cv2.dilate(safe_core, dilate_kernel, iterations=1) > 0 | |
| stray = (alpha > 10) & (~safe_zone) | |
| if not stray.any(): | |
| return png_bytes | |
| alpha[stray] = 0 | |
| arr[:, :, 3] = alpha | |
| out = Image.fromarray(arr, "RGBA") | |
| buf = io.BytesIO() | |
| out.save(buf, format="PNG") | |
| return buf.getvalue() | |
| def fill_enclosed_holes(png_bytes, max_hole_area_ratio=0.02): | |
| """Si queda algún agujero (transparente) completamente rodeado de | |
| primer plano -como una cara que el modelo borró por error-, lo | |
| detecta geométricamente y lo hace opaco. El color ya es correcto en | |
| todos lados gracias a recolor_from_original, así que acá solo hay | |
| que tocar la transparencia.""" | |
| img = Image.open(io.BytesIO(png_bytes)).convert("RGBA") | |
| arr = np.array(img) | |
| alpha = arr[:, :, 3] | |
| fg = (alpha > 10).astype(np.uint8) * 255 | |
| # Sella canales delgados que conectan un "agujero" (como una cara | |
| # borrada por error) con el fondo real, para que se detecten como | |
| # una isla encerrada en vez de quedar pegados al fondo. | |
| kernel = np.ones((7, 7), np.uint8) | |
| fg_closed = cv2.morphologyEx(fg, cv2.MORPH_CLOSE, kernel) | |
| contours, hierarchy = cv2.findContours(fg_closed, cv2.RETR_CCOMP, cv2.CHAIN_APPROX_SIMPLE) | |
| if hierarchy is None: | |
| return png_bytes | |
| hierarchy = hierarchy[0] | |
| total_area = fg.shape[0] * fg.shape[1] | |
| fill_mask = np.zeros_like(fg) | |
| for i, h in enumerate(hierarchy): | |
| _, _, _, parent = h | |
| if parent != -1: # es un agujero (contorno hijo de otro contorno) | |
| area = cv2.contourArea(contours[i]) | |
| if area < total_area * max_hole_area_ratio: | |
| cv2.drawContours(fill_mask, contours, i, 255, thickness=cv2.FILLED) | |
| if not fill_mask.any(): | |
| return png_bytes | |
| alpha[fill_mask == 255] = 255 | |
| arr[:, :, 3] = alpha | |
| out = Image.fromarray(arr, "RGBA") | |
| buf = io.BytesIO() | |
| out.save(buf, format="PNG") | |
| return buf.getvalue() | |
| def health(): | |
| return {"status": "ok", "version": "erosion-core-v3"} | |
| async def remove_bg(file: UploadFile = File(...), matting: bool = Form(True)): | |
| if not file.content_type or not file.content_type.startswith("image/"): | |
| raise HTTPException(status_code=400, detail="El archivo debe ser una imagen.") | |
| input_bytes = await file.read() | |
| try: | |
| pil_img = Image.open(io.BytesIO(input_bytes)).convert("RGB") | |
| pil_img = resize_if_needed(pil_img) | |
| buf_in = io.BytesIO() | |
| pil_img.save(buf_in, format="PNG") | |
| resized_bytes = buf_in.getvalue() | |
| use_matting = matting and (pil_img.width * pil_img.height <= MATTING_MAX_PIXELS) | |
| if use_matting: | |
| output_bytes = remove( | |
| resized_bytes, | |
| session=session, | |
| alpha_matting=True, | |
| alpha_matting_foreground_threshold=240, | |
| alpha_matting_background_threshold=10, | |
| alpha_matting_erode_size=10, | |
| ) | |
| else: | |
| # Sin alpha matting: usa la máscara del modelo tal cual, sin el | |
| # refinamiento extra. Más robusto en fotos con mucha gente o | |
| # bordes complejos, aunque el cabello suelto se ve algo peor. | |
| output_bytes = remove(resized_bytes, session=session) | |
| output_bytes = recolor_from_original(output_bytes, np.array(pil_img)) | |
| output_bytes = remove_stray_specks(output_bytes) | |
| output_bytes = fill_enclosed_holes(output_bytes) | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=f"No se pudo procesar la imagen: {e}") | |
| return Response(content=output_bytes, media_type="image/png") | |
| def estimate_text_color(image_bgr, x1, y1, x2, y2): | |
| """Aproxima el color del texto separando, dentro del recuadro, los | |
| píxeles del texto de los del fondo (el texto suele ser la clase | |
| minoritaria dentro de su propio recuadro).""" | |
| crop = image_bgr[y1:y2, x1:x2] | |
| if crop.size == 0: | |
| return "#000000" | |
| gray = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY) | |
| _, thresh = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) | |
| if np.sum(thresh == 255) > np.sum(thresh == 0): | |
| thresh = cv2.bitwise_not(thresh) | |
| text_pixels = crop[thresh == 255] | |
| if len(text_pixels) == 0: | |
| text_pixels = crop.reshape(-1, 3) | |
| b, g, r = np.mean(text_pixels, axis=0) | |
| return "#{:02x}{:02x}{:02x}".format(int(r), int(g), int(b)) | |
| def identify_font(pil_crop): | |
| """Devuelve el nombre de la fuente (de un set de 48 fuentes estándar) | |
| más parecida a la del recorte de texto dado.""" | |
| try: | |
| crop = pil_crop.convert("RGB") | |
| # Los recortes chicos le dan poco detalle al clasificador; agrandarlos | |
| # mejora bastante la precisión del reconocimiento. | |
| if min(crop.size) < 200: | |
| ratio = 200 / min(crop.size) | |
| crop = crop.resize((int(crop.width * ratio), int(crop.height * ratio)), Image.LANCZOS) | |
| inputs = font_processor(images=crop, return_tensors="pt") | |
| with torch.no_grad(): | |
| logits = font_model(**inputs).logits | |
| predicted_id = logits.argmax(-1).item() | |
| return font_model.config.id2label[predicted_id] | |
| except Exception: | |
| return None | |
| def font_file(name: str): | |
| """Consigue el archivo .ttf real de una fuente de Google Fonts, para | |
| poder incrustarla de verdad en el PDF (no solo mostrarla en pantalla). | |
| Le pedimos a Google con un User-Agent viejo a propósito: así el | |
| servidor responde con .ttf en vez del .woff2 que le da a los | |
| navegadores modernos (jsPDF necesita .ttf).""" | |
| try: | |
| css_url = f"https://fonts.googleapis.com/css2?family={name.replace(' ', '+')}:wght@700" | |
| headers = {"User-Agent": "Mozilla/5.0 (Windows NT 6.1; rv:2.0.1) Gecko/20100101 Firefox/4.0.1"} | |
| css_resp = requests.get(css_url, headers=headers, timeout=15) | |
| css_resp.raise_for_status() | |
| match = re.search(r"url\((https://fonts\.gstatic\.com/[^)]+\.ttf)\)", css_resp.text) | |
| if not match: | |
| raise HTTPException(status_code=404, detail="No se encontró el archivo .ttf para esa fuente.") | |
| ttf_resp = requests.get(match.group(1), timeout=15) | |
| ttf_resp.raise_for_status() | |
| return Response(content=ttf_resp.content, media_type="font/ttf") | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=f"No se pudo conseguir la fuente: {e}") | |
| async def extract_text(file: UploadFile = File(...)): | |
| if not file.content_type or not file.content_type.startswith("image/"): | |
| raise HTTPException(status_code=400, detail="El archivo debe ser una imagen.") | |
| input_bytes = await file.read() | |
| try: | |
| pil_img = Image.open(io.BytesIO(input_bytes)).convert("RGB") | |
| pil_img = resize_if_needed(pil_img, max_side=1600) | |
| image_bgr = cv2.cvtColor(np.array(pil_img), cv2.COLOR_RGB2BGR) | |
| h, w = image_bgr.shape[:2] | |
| results = ocr_reader.readtext(image_bgr) | |
| texts = [] | |
| mask = np.zeros((h, w), dtype=np.uint8) | |
| for bbox, text, conf in results: | |
| if conf < 0.35 or not text.strip(): | |
| continue | |
| xs = [p[0] for p in bbox] | |
| ys = [p[1] for p in bbox] | |
| x1 = max(0, int(min(xs)) - TEXT_PADDING) | |
| y1 = max(0, int(min(ys)) - TEXT_PADDING) | |
| x2 = min(w, int(max(xs)) + TEXT_PADDING) | |
| y2 = min(h, int(max(ys)) + TEXT_PADDING) | |
| if x2 <= x1 or y2 <= y1: | |
| continue | |
| color = estimate_text_color(image_bgr, x1, y1, x2, y2) | |
| crop_rgb = cv2.cvtColor(image_bgr[y1:y2, x1:x2], cv2.COLOR_BGR2RGB) | |
| font_name = identify_font(Image.fromarray(crop_rgb)) | |
| texts.append({ | |
| "text": text, | |
| "x": x1, | |
| "y": y1, | |
| "width": x2 - x1, | |
| "height": y2 - y1, | |
| "fontSize": max(8, y2 - y1), | |
| "color": color, | |
| "font": font_name, | |
| }) | |
| cv2.rectangle(mask, (x1, y1), (x2, y2), 255, thickness=-1) | |
| if texts: | |
| mask_pil = Image.fromarray(mask) | |
| background_pil = lama(pil_img, mask_pil) | |
| else: | |
| background_pil = pil_img | |
| buf = io.BytesIO() | |
| background_pil.save(buf, format="PNG") | |
| background_b64 = base64.b64encode(buf.getvalue()).decode("ascii") | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=f"No se pudo procesar la imagen: {e}") | |
| return { | |
| "width": w, | |
| "height": h, | |
| "background": "data:image/png;base64," + background_b64, | |
| "texts": texts, | |
| } | |