fjavier-aj's picture
Update app.py
384cf24 verified
import gradio as gr
import numpy as np
from PIL import Image
import cv2
import zipfile, os, tempfile
from typing import List, Tuple
# ---------------- Utils ----------------
def _iou(a, b):
"""Calcula la intersección sobre la unión (IoU) para dos cajas."""
ax, ay, aw, ah = a
bx, by, bw, bh = b
inter_w = max(0, min(ax+aw, bx+bw) - max(ax, bx))
inter_h = max(0, min(ay+ah, by+bh) - max(ay, by))
inter = inter_w * inter_h
if inter == 0: return 0.0
union = aw*ah + bw*bh - inter
return inter / union
def _nms(boxes: List[Tuple[int,int,int,int]], thr=0.5):
"""Non-Maximum Suppression para eliminar cajas duplicadas."""
keep = []
for b in boxes:
if all(_iou(b, k) < thr for k in keep):
keep.append(b)
return keep
def _largest_component_bbox(mask):
"""Devuelve la caja delimitadora del componente conectado más grande."""
cnts, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
if not cnts: return None
c = max(cnts, key=cv2.contourArea)
return cv2.boundingRect(c)
def _background_from_corners(img_np):
"""Estima el color de fondo promediando las 4 esquinas."""
corners = np.array([
img_np[0,0,:3], img_np[0,-1,:3],
img_np[-1,0,:3], img_np[-1,-1,:3]
], dtype=np.float32)
return corners.mean(0).astype(np.uint8)
def _auto_diff_threshold(img_np, bg_rgb):
"""Calcula automáticamente el umbral de diferencia de color."""
diff = np.linalg.norm(img_np[:,:,:3].astype(np.int16) - bg_rgb.astype(np.int16), axis=2)
diff_u8 = np.clip(diff, 0, 255).astype(np.uint8)
thr_val, _ = cv2.threshold(diff_u8, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
return float(thr_val)
# ---------------- Máscaras de primer plano ----------------
def _foreground_mask_hybrid(img_np, bg_rgb, diff_thr):
"""Genera máscara basada en diferencia de color y saturación HSV."""
diff = np.linalg.norm(img_np[:,:,:3].astype(np.int16) - bg_rgb.astype(np.int16), axis=2)
mask_diff = (diff > max(20.0, min(80.0, diff_thr))).astype(np.uint8) * 255
bgr = cv2.cvtColor(img_np, cv2.COLOR_RGBA2BGR)
hsv = cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV)
H,S,V = cv2.split(hsv)
S_thr = int(max(25, min(110, np.percentile(S, 70))))
V_thr = int(max(60, min(200, np.percentile(V, 40))))
mask_sat = cv2.inRange(hsv, (0, S_thr, V_thr), (179, 255, 255))
mask = cv2.bitwise_or(mask_diff, mask_sat)
k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (7,7))
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, k)
mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, k)
return mask
def _foreground_mask_kmeans_lab(img_np):
"""Genera máscara usando clustering K-Means en espacio de color LAB (más lento pero preciso)."""
bgr = cv2.cvtColor(img_np, cv2.COLOR_RGBA2BGR)
lab = cv2.cvtColor(bgr, cv2.COLOR_BGR2LAB)
H, W = lab.shape[:2]
Z = lab.reshape(-1,3).astype(np.float32)
N = Z.shape[0]
sample_idx = np.linspace(0, N-1, min(60000, N), dtype=np.int32)
Zs = Z[sample_idx]
criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 20, 1.0)
_compact, labels, centers = cv2.kmeans(Zs, 3, None, criteria, 2, cv2.KMEANS_PP_CENTERS)
dists = np.linalg.norm(Z[:,None,:] - centers[None,:,:], axis=2)
full_labels = np.argmin(dists, axis=1).reshape(H, W)
areas = np.array([(full_labels==i).sum() for i in range(3)], dtype=np.float32)
varL = np.array([np.var(Z[full_labels.reshape(-1)==i, 0]) for i in range(3)], dtype=np.float32)
area_norm = areas / (areas.max()+1e-6)
var_norm = varL / (varL.max()+1e-6)
score = -area_norm + 0.5*var_norm
bg_id = int(np.argmin(score))
mask = (full_labels != bg_id).astype(np.uint8) * 255
k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (7,7))
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, k)
mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, k)
return mask
# ---------------- Helpers ----------------
def _fill_holes(mask):
"""Rellena huecos internos en una máscara binaria."""
h, w = mask.shape
flood = np.zeros((h+2, w+2), np.uint8)
inv = cv2.bitwise_not(mask)
cv2.floodFill(inv, flood, (0, 0), 255)
holes = cv2.bitwise_not(inv)
return cv2.bitwise_or(mask, holes)
def _split_touching_blocks(roi_mask, min_gap_ratio=0.04):
"""Separa bloques verticalmente si están pegados pero hay un ligero estrechamiento."""
m = roi_mask.copy()
H, W = m.shape
row_fill = (m > 0).sum(axis=1) / max(1, W)
gap_rows = np.where(row_fill <= min_gap_ratio)[0]
if gap_rows.size == 0:
return [m]
masks = []
start = 0
for gap in gap_rows:
if gap - start > 5:
submask = np.zeros_like(m)
submask[start:gap, :] = m[start:gap, :]
if submask.any():
masks.append(submask)
start = gap+1
submask = np.zeros_like(m)
submask[start:, :] = m[start:, :]
if submask.any():
masks.append(submask)
return masks if masks else [m]
# ---------------- REFINAMIENTO DE MÁSCARA (CORREGIDO) ----------------
def _refine_mask(dirty_mask):
"""
Limpia la máscara final para el recorte.
1. CIERRE (CLOSE): Aplica 'pegamento' para cerrar paredes rotas o bordes finos.
2. Rellena SOLIDEZ: Dibuja el contorno externo relleno para tapar inputs trasparentes.
3. Elimina BORDES: Aplica erosión para quitar el halo blanco/gris.
"""
# --- PASO 1: Morphological Closing (SOLUCIÓN AL BLOQUE ROTO) ---
# Esto conecta partes del bloque que están casi tocándose pero separadas por
# un píxel claro (común en bordes de inputs o brillos).
k_close = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5,5))
closed_mask = cv2.morphologyEx(dirty_mask, cv2.MORPH_CLOSE, k_close)
# --- PASO 2: Rellenar inputs (SOLUCIÓN AL INPUT TRANSPARENTE) ---
# Buscamos contornos sobre la máscara YA CERRADA
cnts, _ = cv2.findContours(closed_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
if not cnts:
return dirty_mask # Fallback si algo sale mal
# Tomamos el contorno más grande (el bloque principal)
c = max(cnts, key=cv2.contourArea)
# Creamos la máscara sólida final
solid_mask = np.zeros_like(dirty_mask)
cv2.drawContours(solid_mask, [c], -1, 255, thickness=cv2.FILLED)
# --- PASO 3: Erosión (SOLUCIÓN AL HALO BLANCO) ---
# Quitamos 1 píxel del borde para eliminar el antialiasing sucio
kernel_erode = np.ones((3,3), np.uint8)
eroded_mask = cv2.erode(solid_mask, kernel_erode, iterations=1)
return eroded_mask
# ---------------- Detección ----------------
def _detect_blocks(img_np, fg_mask):
"""Detecta bloques candidatos basándose en la máscara inicial."""
cnts, _ = cv2.findContours(fg_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
H, W = img_np.shape[:2]
boxes = []
for c in cnts:
x,y,w,h = cv2.boundingRect(c)
roi = np.zeros((h,w), np.uint8)
cv2.drawContours(roi, [c - [x,y]], -1, 255, thickness=cv2.FILLED)
roi = _fill_holes(roi)
parts = _split_touching_blocks(roi, min_gap_ratio=0.04)
for part in parts:
if part.sum() == 0:
continue
cx,cy,cw,ch = cv2.boundingRect(part)
area = int(part.astype(bool).sum())
rect_area = cw*ch
rectangularity = area / max(1.0, rect_area)
# Filtros de tamaño y forma para descartar ruido
if area < 1200 or cw < 16 or ch < 16:
continue
if rectangularity < 0.30:
continue
if rect_area > 0.92*W*H: # Ignorar si ocupa casi toda la pantalla (probablemente fondo mal detectado)
continue
boxes.append((x+cx, y+cy, cw, ch))
boxes = _nms(boxes, thr=0.30)
return boxes
# ---------------- Anti-ghost ----------------
def _bleed_colors(bgr, mask_255):
"""Expande el color hacia afuera para evitar bordes blancos al recortar."""
outside = cv2.bitwise_not(mask_255)
return cv2.inpaint(bgr, outside, 3, cv2.INPAINT_TELEA)
# ---------------- Mask Picker ----------------
def _pick_best_mask(img_rgba):
"""Prueba varias estrategias de máscara y elige la que detecta mejores bloques."""
img_np = np.array(img_rgba)
img_bgr = cv2.cvtColor(img_np, cv2.COLOR_RGBA2BGR)
bg_rgb = _background_from_corners(img_np)
diff_thr = _auto_diff_threshold(img_np, bg_rgb)
m1 = _foreground_mask_hybrid(img_np, bg_rgb, diff_thr)
m2 = _foreground_mask_hybrid(img_np, bg_rgb, max(20.0, diff_thr-12))
m3 = _foreground_mask_kmeans_lab(img_np)
candidates = [m1, m2, m3]
scored = []
for m in candidates:
b = _detect_blocks(img_np, m)
scored.append((len(b), b, m))
scored.sort(key=lambda t: t[0], reverse=True)
best_count, best_blocks, best_mask = scored[0]
return best_blocks, best_mask, img_np, img_bgr
# ---------------- Pipeline ----------------
def extract_blocks(image: Image.Image):
if image is None:
raise gr.Error("Sube una imagen.")
base = getattr(image, "name", "output").rsplit(".", 1)[0] or "output"
img_rgba = image.convert("RGBA")
# Paso 1: Detección general
blocks, mask, img_np, img_bgr = _pick_best_mask(img_rgba)
if not blocks:
raise gr.Error("No se detectaron bloques. Prueba con otra captura o mayor contraste.")
tmpdir = tempfile.mkdtemp()
zip_path = os.path.join(tmpdir, f"{base}.zip")
preview_path = os.path.join(tmpdir, f"{base}_preview.png")
annotated = img_bgr.copy()
with zipfile.ZipFile(zip_path, "w") as zf:
for i, (x,y,w,h) in enumerate(blocks, start=1):
# Recorte inicial sobre la máscara "sucia" (con huecos y ruido)
roi_mask_dirty = mask[y:y+h, x:x+w]
# Ajustar bbox al contenido real
bbox = _largest_component_bbox(roi_mask_dirty)
if not bbox: continue
bx, by, bw, bh = bbox
pad = 2
bx2 = max(0, bx - pad); by2 = max(0, by - pad)
bw2 = min(w, bx + bw + pad) - bx2
bh2 = min(h, by + bh + pad) - by2
# Recortes ajustados
block_bgr = img_bgr[y+by2:y+by2+bh2, x+bx2:x+bx2+bw2].copy()
block_mask_dirty = roi_mask_dirty[by2:by2+bh2, bx2:bx2+bw2].copy()
# --- CORRECCIÓN FINAL ---
# Aquí aplicamos la lógica nueva para arreglar agujeros y bordes
block_mask_clean = _refine_mask(block_mask_dirty)
# Anti-ghosting (Bleed colors)
block_bgr = _bleed_colors(block_bgr, block_mask_clean)
# Composición final
alpha = block_mask_clean
rgba = np.dstack([cv2.cvtColor(block_bgr, cv2.COLOR_BGR2RGB), alpha])
out_name = f"{i:02d}.png"
_, buf = cv2.imencode(".png", cv2.cvtColor(rgba, cv2.COLOR_RGBA2BGRA))
zf.writestr(out_name, buf)
# Dibujar en preview
cv2.rectangle(annotated, (x, y), (x+w, y+h), (0,255,0), 2)
cv2.putText(annotated, out_name, (x, y-5), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0,255,0), 1)
cv2.imwrite(preview_path, annotated)
return Image.fromarray(cv2.cvtColor(annotated, cv2.COLOR_BGR2RGB)), zip_path
# ---------------- Interfaz ----------------
with gr.Blocks() as demo:
gr.Markdown("## ✂️ Extractor de bloques tipo Scratch, makeCode, Code.org — Vista previa y descarga ZIP")
with gr.Row():
inp = gr.Image(type="pil", label="Subir captura")
with gr.Column():
out_prev = gr.Image(type="pil", label="Vista previa de detección")
out_zip = gr.File(label="Descargar ZIP")
btn = gr.Button("Procesar")
btn.click(extract_blocks, inputs=inp, outputs=[out_prev, out_zip])
if __name__ == "__main__":
demo.launch()