Spaces:
Sleeping
Sleeping
| """ | |
| Hide My Data — Desidentificación visual de imágenes médicas. | |
| Interfaz web (Gradio) para Hugging Face Spaces. | |
| Reutiliza el pipeline del proyecto (config.py + pipeline/): | |
| detección YOLO (CLAHE) → OCR → anonimización (blur / LaMa / SSL) | |
| y añade cifrado reversible de los metadatos extraídos (Fernet) + descifrado. | |
| Requisitos en el Space: ver requirements.txt. | |
| Pesos necesarios: models/yolo/clahe_canny/weights/best.pt (y best.pt del SSL si se usa). | |
| """ | |
| import os | |
| os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") # sin entorno gráfico en el Space | |
| import io | |
| import zipfile | |
| import tempfile | |
| from datetime import datetime | |
| from pathlib import Path | |
| import cv2 | |
| import numpy as np | |
| from cryptography.fernet import Fernet | |
| import gradio as gr | |
| # ── Parche defensivo para el bug "argument of type 'bool' is not iterable" de | |
| # gradio_client.utils (presente en gradio 4.44; inocuo en gradio 5). Se aplica | |
| # solo si las funciones existen, así no estorba en versiones donde ya está resuelto. | |
| try: | |
| import gradio_client.utils as _gcu | |
| if hasattr(_gcu, "get_type"): | |
| _orig_get_type = _gcu.get_type | |
| def _safe_get_type(schema): | |
| if not isinstance(schema, dict): | |
| return "Any" | |
| return _orig_get_type(schema) | |
| _gcu.get_type = _safe_get_type | |
| if hasattr(_gcu, "_json_schema_to_python_type"): | |
| _orig_js2pt = _gcu._json_schema_to_python_type | |
| def _safe_js2pt(schema, defs=None): | |
| if isinstance(schema, bool): | |
| return "bool" | |
| try: | |
| return _orig_js2pt(schema, defs) | |
| except Exception: | |
| return "Any" | |
| _gcu._json_schema_to_python_type = _safe_js2pt | |
| except Exception: | |
| pass | |
| import config | |
| from pipeline.preprocess import clahe_canny | |
| from pipeline.anonymizer import anonymize | |
| from pipeline.ocr import run_ocr | |
| CLASS_NAMES = config.CLASS_NAMES | |
| CLASS_COLORS = config.CLASS_COLORS # BGR | |
| CLASS_LABELS = {"name": "Nombre", "id": "ID", "age": "Edad", | |
| "date": "Fecha", "time": "Hora"} | |
| METHOD_MAP = { | |
| "Borrado inteligente (LaMa)": "lama", | |
| "Parche difuminado (blur)": "blur", | |
| } | |
| # ── Modelos (carga perezosa, una sola vez) ─────────────────────────────────────── | |
| _yolo = None | |
| def get_yolo(): | |
| global _yolo | |
| if _yolo is None: | |
| from ultralytics import YOLO | |
| _yolo = YOLO(str(config.YOLO_CLAHE_W)) | |
| return _yolo | |
| # ── Helpers de visión ───────────────────────────────────────────────────────────── | |
| def detect(bgr): | |
| """Detecta sobre la imagen realzada con CLAHE (entrada real del modelo).""" | |
| inp = clahe_canny(bgr) | |
| return get_yolo().predict(inp, conf=config.CONF, verbose=False)[0].boxes | |
| def draw_boxes(bgr, boxes, selected_ids): | |
| out = bgr.copy() | |
| for b in boxes: | |
| cid = int(b.cls.item()) | |
| if cid not in selected_ids: | |
| continue | |
| x1, y1, x2, y2 = map(int, b.xyxy[0].tolist()) | |
| color = CLASS_COLORS.get(cid, (255, 255, 255)) | |
| cv2.rectangle(out, (x1, y1), (x2, y2), color, 2) | |
| cv2.putText(out, CLASS_NAMES[cid], (x1, max(12, y1 - 4)), | |
| cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 1, cv2.LINE_AA) | |
| return out | |
| def _bgr2rgb(bgr): | |
| return cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB) | |
| # ── Pestaña 1: Anonimizar ───────────────────────────────────────────────────────── | |
| def run_anonymize(files, selected_labels, method_label, progress=gr.Progress()): | |
| # gr.File(file_count="multiple") devuelve una lista; por robustez admitimos | |
| # también un único path o None. | |
| if files is None: | |
| files = [] | |
| elif isinstance(files, (str, Path)): | |
| files = [files] | |
| if not files: | |
| return [], [], None, "⚠️ Sube al menos una imagen." | |
| selected_ids = {i for i, n in enumerate(CLASS_NAMES) | |
| if CLASS_LABELS[n] in selected_labels} | |
| if not selected_ids: | |
| return [], [], None, "⚠️ Selecciona al menos una categoría a anonimizar." | |
| method = METHOD_MAP[method_label] | |
| inpaint_model = None | |
| ts = datetime.now().strftime("%Y%m%d_%H%M%S") | |
| cls_tag = "_".join(sorted(CLASS_NAMES[i] for i in selected_ids)) | |
| work = Path(tempfile.mkdtemp()) | |
| root_out = work / f"hidemydata__{ts}__{cls_tag}" | |
| d_img, d_plain, d_enc, d_key = (root_out / s for s in | |
| ("images", "metadata_plain", "metadata_encrypted", "keys")) | |
| for d in (d_img, d_plain, d_enc, d_key): | |
| d.mkdir(parents=True, exist_ok=True) | |
| fernet_key = Fernet.generate_key() | |
| fernet = Fernet(fernet_key) | |
| (d_key / "decryption_key.txt").write_bytes(fernet_key) | |
| det_gallery, anon_gallery, log = [], [], [] | |
| n_total = len(files) | |
| for idx, fpath in enumerate(files, 1): | |
| progress((idx - 1) / n_total, desc=f"Procesando {idx}/{n_total}…") | |
| try: | |
| bgr = cv2.imread(str(fpath)) | |
| if bgr is None: | |
| log.append(f"[omitida] no se pudo leer {Path(fpath).name}") | |
| continue | |
| img_id = f"{idx:05d}" | |
| boxes = detect(bgr) | |
| filtered = [b for b in boxes if int(b.cls.item()) in selected_ids] | |
| anon = anonymize(method, bgr, filtered, inpaint_model) | |
| cv2.imwrite(str(d_img / f"{img_id}.png"), anon) | |
| det_gallery.append((_bgr2rgb(draw_boxes(bgr, boxes, selected_ids)), Path(fpath).name)) | |
| anon_gallery.append((_bgr2rgb(anon), Path(fpath).name)) | |
| # OCR → metadatos en claro + cifrados | |
| fields = [] | |
| try: | |
| for det in run_ocr(bgr, filtered)["detections"]: | |
| t = det.get("text", "").strip() | |
| if t: | |
| fields.append(f"{CLASS_LABELS.get(det['class_name'], det['class_name'])}: {t}") | |
| except Exception as e: | |
| log.append(f"[aviso OCR] {Path(fpath).name}: {e}") | |
| plain = "\n".join(fields) or "(sin texto detectado)" | |
| (d_plain / f"{img_id}.txt").write_text(plain, encoding="utf-8") | |
| (d_enc / f"{img_id}.enc").write_bytes(fernet.encrypt(plain.encode("utf-8"))) | |
| log.append(f"[{idx}/{n_total}] {Path(fpath).name} → {img_id}.png ({len(filtered)} cajas)") | |
| except Exception as e: | |
| log.append(f"[error] {Path(fpath).name}: {e}") | |
| progress(1.0, desc="Empaquetando…") | |
| # ZIP de salida (imágenes + metadatos claros + cifrados + clave) | |
| zip_path = str(work / f"hidemydata__{ts}.zip") | |
| with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf: | |
| for p in root_out.rglob("*"): | |
| if p.is_file(): | |
| zf.write(p, p.relative_to(root_out.parent)) | |
| log.append("\n✓ Listo. El ZIP incluye las imágenes anonimizadas, los metadatos " | |
| "(en claro y cifrados) y la clave de descifrado (keys/decryption_key.txt).") | |
| return det_gallery, anon_gallery, zip_path, "\n".join(log) | |
| # ── Pestaña 2: Descifrar ─────────────────────────────────────────────────────────── | |
| def run_decrypt(enc_files, key_text, key_file): | |
| key = (key_text or "").strip().encode() if key_text else None | |
| if not key and key_file: | |
| key = Path(key_file).read_bytes().strip() | |
| if not key: | |
| return None, "⚠️ Pega la clave o sube el fichero decryption_key.txt." | |
| if not enc_files: | |
| return None, "⚠️ Sube al menos un fichero .enc." | |
| try: | |
| fernet = Fernet(key) | |
| except Exception as e: | |
| return None, f"⚠️ Clave inválida: {e}" | |
| work = Path(tempfile.mkdtemp()) | |
| out_dir = work / "descifrado" | |
| out_dir.mkdir(parents=True, exist_ok=True) | |
| lines, ok = [], 0 | |
| for f in enc_files: | |
| name = Path(f).name | |
| try: | |
| plain = fernet.decrypt(Path(f).read_bytes()).decode("utf-8") | |
| (out_dir / (Path(f).stem + ".txt")).write_text(plain, encoding="utf-8") | |
| lines.append(f"── {name} ──\n{plain}\n") | |
| ok += 1 | |
| except Exception as e: | |
| lines.append(f"✗ {name}: {e}\n") | |
| zip_path = str(work / "descifrado.zip") | |
| with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf: | |
| for p in out_dir.glob("*.txt"): | |
| zf.write(p, p.name) | |
| header = f"✓ {ok}/{len(enc_files)} ficheros descifrados.\n\n" | |
| return zip_path, header + "\n".join(lines) | |
| # ── Interfaz ──────────────────────────────────────────────────────────────────── | |
| # Paleta tomada de la app de escritorio (PyQt6): navy + teal. | |
| NAVY, PANEL, INPUT, BORDER = "#0D1B2A", "#111F30", "#0A1520", "#1E3048" | |
| TEAL, INK, MUTED = "#00C9A7", "#F0F4F8", "#6B829E" | |
| CLASS_HEX = {"name": "#FF6B6B", "id": "#FFD93D", "age": "#6BCB77", | |
| "date": "#4D96FF", "time": "#C77DFF"} | |
| THEME = gr.themes.Base( | |
| primary_hue=gr.themes.colors.teal, | |
| neutral_hue=gr.themes.colors.slate, | |
| font=[gr.themes.GoogleFont("Inter"), "Segoe UI", "sans-serif"], | |
| ).set( | |
| body_background_fill=NAVY, | |
| body_text_color=INK, | |
| background_fill_primary=PANEL, | |
| background_fill_secondary=NAVY, | |
| block_background_fill=PANEL, | |
| block_border_color=BORDER, | |
| block_border_width="1px", | |
| block_radius="10px", | |
| block_label_text_color=TEAL, | |
| block_title_text_color=TEAL, | |
| input_background_fill=INPUT, | |
| input_border_color=BORDER, | |
| border_color_primary=BORDER, | |
| button_primary_background_fill=TEAL, | |
| button_primary_background_fill_hover="#00DDB0", | |
| button_primary_text_color="#06231D", | |
| button_secondary_background_fill="#1A3A5C", | |
| button_secondary_text_color=INK, | |
| color_accent_soft="#1E3048", | |
| ) | |
| CSS = f""" | |
| .gradio-container {{ background: {NAVY} !important; max-width: 1180px !important; }} | |
| footer {{ display: none !important; }} | |
| #hmd-header {{ | |
| display:flex; align-items:center; gap:14px; | |
| padding:14px 20px; margin-bottom:6px; | |
| background:#162032; border:1px solid {BORDER}; | |
| border-bottom:2px solid {TEAL}55; border-radius:12px; | |
| }} | |
| #hmd-logo {{ color:{TEAL}; font-size:24px; font-weight:800; letter-spacing:2px; }} | |
| #hmd-sub {{ color:{MUTED}; font-size:13px; }} | |
| #hmd-badge {{ | |
| margin-left:auto; color:{TEAL}; background:{TEAL}22; | |
| border:1px solid {TEAL}44; border-radius:9px; padding:3px 11px; | |
| font-size:12px; font-weight:600; | |
| }} | |
| .hmd-legend {{ display:flex; gap:16px; flex-wrap:wrap; font-size:13px; color:{INK}; padding:2px 2px 8px; }} | |
| .hmd-legend span {{ display:inline-flex; align-items:center; gap:6px; }} | |
| .hmd-dot {{ width:12px; height:12px; border-radius:3px; display:inline-block; }} | |
| """ | |
| HEADER = f""" | |
| <div id="hmd-header"> | |
| <div id="hmd-logo">HideMyData</div> | |
| <div id="hmd-sub">Desidentificación visual de imágenes médicas · privacidad con utilidad clínica</div> | |
| <div id="hmd-badge">v1.0</div> | |
| </div> | |
| """ | |
| LEGEND = ('<div class="hmd-legend">' + "".join( | |
| f'<span><span class="hmd-dot" style="background:{CLASS_HEX[n]}"></span>{CLASS_LABELS[n]}</span>' | |
| for n in CLASS_NAMES) + "</div>") | |
| with gr.Blocks(title="HideMyData", theme=THEME, css=CSS) as demo: | |
| gr.HTML(HEADER) | |
| with gr.Tab(" Anonimizar "): | |
| with gr.Row(equal_height=False): | |
| with gr.Column(scale=4): | |
| in_files = gr.File(label="Imágenes (arrastra una o varias · .png / .jpg)", | |
| file_count="multiple", file_types=["image"], | |
| type="filepath", height=180) | |
| gr.HTML(LEGEND) | |
| cls_sel = gr.CheckboxGroup( | |
| choices=[CLASS_LABELS[n] for n in CLASS_NAMES], | |
| value=[CLASS_LABELS[n] for n in CLASS_NAMES], | |
| label="Categorías a ocultar") | |
| method_sel = gr.Radio(choices=list(METHOD_MAP.keys()), | |
| value="Borrado inteligente (LaMa)", | |
| label="Método de anonimización") | |
| btn = gr.Button("Anonimizar", variant="primary", size="lg") | |
| with gr.Column(scale=6): | |
| with gr.Row(): | |
| g_det = gr.Gallery(label="Detección", columns=2, height=300, | |
| object_fit="contain") | |
| g_anon = gr.Gallery(label="Anonimizadas", columns=2, height=300, | |
| object_fit="contain") | |
| out_zip = gr.File(label="Descargar resultados (ZIP: imágenes + metadatos + clave)") | |
| log = gr.Textbox(label="Registro", lines=7, max_lines=14, show_copy_button=True) | |
| btn.click(run_anonymize, [in_files, cls_sel, method_sel], | |
| [g_det, g_anon, out_zip, log]) | |
| with gr.Tab(" Descifrar metadatos "): | |
| gr.Markdown("Recupera los datos originales subiendo los ficheros **`.enc`** y la **clave** " | |
| "(`decryption_key.txt`) que se generó al anonimizar.") | |
| with gr.Row(equal_height=False): | |
| with gr.Column(scale=4): | |
| enc_files = gr.File(label="Ficheros .enc", file_count="multiple", | |
| type="filepath", height=150) | |
| key_text = gr.Textbox(label="Clave (pega el contenido de decryption_key.txt)", | |
| lines=1, type="password") | |
| key_file = gr.File(label="…o sube decryption_key.txt", type="filepath", height=90) | |
| btn_dec = gr.Button("Descifrar", variant="primary", size="lg") | |
| with gr.Column(scale=6): | |
| dec_zip = gr.File(label="Descargar textos descifrados (ZIP)") | |
| dec_out = gr.Textbox(label="Contenido descifrado", lines=14, show_copy_button=True) | |
| btn_dec.click(run_decrypt, [enc_files, key_text, key_file], [dec_zip, dec_out]) | |
| if __name__ == "__main__": | |
| demo.launch() | |