Spaces:
Sleeping
Sleeping
File size: 13,192 Bytes
17d950d 3f71436 2bacef8 17d950d 3f71436 323edcf 2bacef8 17d950d d0d8508 90e92b9 751af1f 3f71436 323edcf 90e92b9 3f71436 90e92b9 2bacef8 f188238 17d950d 3f71436 323edcf 17d950d 951b381 17d950d 4915853 9722f2a d592ccb 9685998 558c0f5 9685998 d592ccb 9685998 d592ccb 9685998 d592ccb 9685998 d592ccb 9685998 d592ccb 9685998 d592ccb 9685998 4915853 17d950d 4915853 17d950d e653f95 e858b54 e653f95 17d950d 4915853 17d950d 751af1f 90e92b9 9722f2a 90e92b9 d0d8508 90e92b9 17d950d d0d8508 17d950d d0d8508 17d950d 4915853 9685998 4915853 90e92b9 d0d8508 3f71436 323edcf 2bacef8 323edcf 2bacef8 3f71436 323edcf 3f71436 323edcf 3f71436 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 | 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=["*"],
)
@app.on_event("startup")
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()
@app.get("/")
def health():
return {"status": "ok", "version": "erosion-core-v3"}
@app.post("/remove-bg")
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
@app.get("/font-file")
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}")
@app.post("/extract-text")
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,
}
|