import gradio as gr from PIL import Image import cv2 import numpy as np import os import zipfile import hashlib import logging from pathlib import Path from PIL import ExifTags import plotly.graph_objects as go # Configuración de logs logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) # Directorio de evidencia evidence_dir = "/home/user/app" os.makedirs(evidence_dir, exist_ok=True) logger.info(f"Directorio de evidencia creado en: {evidence_dir}") def obtener_metadatos(imagen): try: exif_data = imagen.getexif() if not exif_data: return {} metadata = {} for tag_id, value in exif_data.items(): try: tag = ExifTags.TAGS.get(tag_id, tag_id) metadata[tag] = value except Exception as e: logger.debug(f"Error al procesar etiqueta EXIF: {str(e)}") return metadata except Exception as e: logger.error(f"Error al obtener metadatos: {str(e)}") return {} def obtener_coordenadas(exif_data): if not exif_data or "GPSInfo" not in exif_data: return None try: gps_info = exif_data["GPSInfo"] if not gps_info: return None def gps_to_degrees(coord): d, m, s = coord return d + (m / 60.0) + (s / 3600.0) lat = gps_info.get(2) lon = gps_info.get(4) lat_ref = gps_info.get(1) lon_ref = gps_info.get(3) if lat and lon and lat_ref and lon_ref: lat_deg = gps_to_degrees(lat) lon_deg = gps_to_degrees(lon) if lat_ref == "S": lat_deg = -lat_deg if lon_ref == "W": lon_deg = -lon_deg return lat_deg, lon_deg except Exception as e: logger.error(f"Error al procesar coordenadas GPS: {str(e)}") return None def calcular_hash(imagen): return hashlib.sha3_256(imagen.tobytes()).hexdigest() def analizar_manipulacion(imagen, metadatos): manipulada = False razones = [] if imagen.mode == "P": razones.append("La imagen tiene marcas de agua o es una imagen indexada.") manipulada = True if "Software" in metadatos: razones.append(f"La imagen fue editada con: {metadatos['Software']}") manipulada = True return manipulada, razones def calcular_porcentaje_ela(mask): if mask is None or mask.size == 0: return 0.0 total_pixeles = mask.size pixeles_detectados = np.count_nonzero(mask) porcentaje = (pixeles_detectados / total_pixeles) * 100 return porcentaje def estimar_probabilidad_manipulacion(porcentaje_ela): if porcentaje_ela < 0.5: return "Muy baja (< 0.5%) - Imagen probablemente auténtica." elif porcentaje_ela < 2.0: return "Baja (0.5% - 2%) - Posible compresión o ruido, pero no manipulación evidente." elif porcentaje_ela < 5.0: return "Moderada (2% - 5%) - Posible edición o retoque localizado." elif porcentaje_ela < 15.0: return "Alta (5% - 15%) - Alta probabilidad de manipulación o zonas borradas." else: return "Muy alta (> 15%) - Manipulación extensa o generación por IA detectada." def realizar_ela(imagen): try: img_np = np.array(imagen.convert("RGB")) img_cv = cv2.cvtColor(img_np, cv2.COLOR_RGB2BGR) # Fuerza la compresión para detectar artefactos quality = 75 temp_path = "/tmp/temp_image.jpg" cv2.imwrite(temp_path, img_cv, [cv2.IMWRITE_JPEG_QUALITY, quality]) img_comprimida = cv2.imread(temp_path) if img_comprimida is None: raise ValueError("No se pudo leer la imagen comprimida.") # Calcula diferencias diferencia = cv2.absdiff(img_cv.astype(np.float32), img_comprimida.astype(np.float32)) scaled_diff = np.clip(diferencia * 15, 0, 255).astype(np.uint8) # Escalado fuerte # Convertir a escala de grises y aplicar umbral más bajo gray_diff = cv2.cvtColor(scaled_diff, cv2.COLOR_BGR2GRAY) _, mask = cv2.threshold(gray_diff, 5, 255, cv2.THRESH_BINARY) # Resalta con color donde hay diferencias ela_color = cv2.applyColorMap(gray_diff, cv2.COLORMAP_TURBO) # Combinar con la original en escala de grises img_gray = cv2.cvtColor(img_cv, cv2.COLOR_BGR2GRAY) img_gray = cv2.cvtColor(img_gray, cv2.COLOR_GRAY2BGR) result = np.where(mask[..., None] > 0, ela_color, img_gray) os.remove(temp_path) return result, mask except Exception as e: logger.error(f"Error en ELA: {str(e)}") error_img = np.zeros((300, 600, 3), dtype=np.uint8) + 30 cv2.putText(error_img, "ERROR AL PROCESAR ELA", (50, 150), cv2.FONT_HERSHEY_SIMPLEX, 1, (255, 255, 255), 2) return error_img, None def crear_graficos_estadisticas(porcentaje_ela, dimensiones, metadatos): """Crear gráficos estadísticos para el análisis ELA""" # Gráfico 1: Porcentaje de manipulación (Barra) fig_bar = go.Figure() fig_bar.add_trace(go.Bar( x=['Áreas Detectadas'], y=[porcentaje_ela], marker_color=['#4CAF50' if porcentaje_ela > 2 else '#36A2EB'], text=[f'{porcentaje_ela:.3f}%'], textposition='auto', )) fig_bar.update_layout( title=dict(text="Porcentaje de Áreas Manipuladas", x=0.5), yaxis_title="Porcentaje (%)", height=250, showlegend=False, margin=dict(l=20, r=20, t=40, b=20) ) # Gráfico 2: Probabilidad de manipulación (Pie) niveles = ['Muy baja', 'Baja', 'Moderada', 'Alta', 'Muy alta'] color_map = ['#00CC96', '#36A2EB', '#FFCE56', '#FF9F40', '#FF6384'] prob_index = 0 if porcentaje_ela >= 15: prob_index = 4 elif porcentaje_ela >= 5: prob_index = 3 elif porcentaje_ela >= 2: prob_index = 2 elif porcentaje_ela >= 0.5: prob_index = 1 fig_pie = go.Figure() fig_pie.add_trace(go.Pie( labels=[niveles[prob_index]], values=[100], hole=.6, marker_colors=[color_map[prob_index]], textinfo='label' )) fig_pie.update_layout( title=dict(text="Nivel de Probabilidad", x=0.5), height=250, showlegend=False, margin=dict(l=20, r=20, t=40, b=20) ) # Gráfico 3: Distribución de píxeles (Box plot) fig_box = go.Figure() fig_box.add_trace(go.Box( y=[porcentaje_ela], name="Distribución ELA", marker_color='#FF6B35', boxpoints='all' )) fig_box.update_layout( title=dict(text="Distribución de Anomalías", x=0.5), yaxis_title="Porcentaje (%)", height=250, showlegend=False, margin=dict(l=20, r=20, t=40, b=20) ) # Gráfico 4: Tendencias temporales (Scatter) fig_scatter = go.Figure() fig_scatter.add_trace(go.Scatter( x=['Compresión', 'Ruido', 'Manipulación'], y=[max(0.1, porcentaje_ela-1), max(0.1, porcentaje_ela-0.5), max(0.1, porcentaje_ela)], mode='lines+markers', line=dict(color='#FF6B35', width=3), marker=dict(size=10) )) fig_scatter.update_layout( title=dict(text="Análisis de Componentes", x=0.5), yaxis_title="Intensidad", height=250, margin=dict(l=20, r=20, t=40, b=20) ) return fig_bar, fig_pie, fig_box, fig_scatter def procesar_imagen(archivo_imagen): if not archivo_imagen: return [None, "❌ **ERROR: Por favor, cargue una imagen antes de analizar.**", None, ""] + [None] * 4 if not os.path.exists(archivo_imagen): return [None, "❌ **ERROR: El archivo de imagen no existe o es inválido.**", None, ""] + [None] * 4 try: img = Image.open(archivo_imagen) logger.info(f"Imagen cargada: {archivo_imagen}") nombre_original = os.path.basename(archivo_imagen) nombre = os.path.splitext(nombre_original)[0] original_path = os.path.join(evidence_dir, f"{nombre}_original.jpg") ela_path = os.path.join(evidence_dir, f"{nombre}_ela.jpg") text_path = os.path.join(evidence_dir, f"{nombre}_analisis.txt") zip_path = os.path.join(evidence_dir, f"{nombre}_errorELA.zip") img.save(original_path, "JPEG") ela_result, mask = realizar_ela(img) if mask is None: raise Exception("No se pudo generar el análisis ELA.") cv2.imwrite(ela_path, ela_result) porcentaje_ela = calcular_porcentaje_ela(mask) probabilidad = estimar_probabilidad_manipulacion(porcentaje_ela) # Crear gráficos estadísticos fig_bar, fig_pie, fig_box, fig_scatter = crear_graficos_estadisticas( porcentaje_ela, img.size, obtener_metadatos(img) ) file_size_bytes = os.path.getsize(archivo_imagen) if file_size_bytes < 1024 * 1024: file_size = f"{file_size_bytes / 1024:.2f} KB" else: file_size = f"{file_size_bytes / (1024 * 1024):.2f} MB" info_basica = f"Nombre del archivo: {nombre_original}\r\n" info_basica += f"Tamaño del archivo: {file_size}\r\n" info_basica += f"Dimensiones: {img.size[0]} x {img.size[1]} píxeles\r\n" info_basica += f"Formato: {img.format}\r\n" info_basica += f"Modo: {img.mode}\r\n\r\n" metadatos = obtener_metadatos(img) info_metadatos = "ANÁLISIS FORENSE DE LOS METADATOS:\r\n\r\n" google_maps_url = None if metadatos: for tag, value in metadatos.items(): if tag == "DateTime": info_metadatos += f"- Fecha y hora de captura: {value}\r\n" elif tag == "Make": info_metadatos += f"- Fabricante de cámara: {value}\r\n" elif tag == "Model": info_metadatos += f"- Modelo de cámara: {value}\r\n" elif tag == "Software": info_metadatos += f"- Software de edición: {value}\r\n" elif tag == "GPSInfo": coords = obtener_coordenadas(metadatos) if coords: lat, lon = coords info_metadatos += f"- Coordenadas GPS: {lat:.6f}, {lon:.6f}\r\n" google_maps_url = f"https://www.google.com/maps?q={lat},{lon}" info_metadatos += f"- Enlace a Google Maps: {google_maps_url}\r\n" else: info_metadatos += "- **Coordenadas GPS:** No se encontraron coordenadas válidas\r\n" else: info_metadatos += f"- **{tag}:** {value}\r\n" else: info_metadatos += "- **Metadatos EXIF:** No se encontraron metadatos EXIF\r\n" sha3_hash = calcular_hash(img) info_metadatos += f"\r\n SHA3-256: {sha3_hash}\r\n\r\n" manipulada, razones = analizar_manipulacion(img, metadatos) info_manipulacion = "ANÁLISIS DE MANIPULACIÓN:\r\n\r\n" info_manipulacion += f"- Porcentaje de áreas detectadas: {porcentaje_ela:.3f}%\r\n" info_manipulacion += f"- Estimación forense: {probabilidad}\r\n\r\n" analysis_text = info_basica + info_metadatos + info_manipulacion with open(text_path, "w", encoding="utf-8", newline="\r\n") as f: f.write(analysis_text) with zipfile.ZipFile(zip_path, "w") as zipf: zipf.write(original_path, os.path.basename(original_path)) zipf.write(ela_path, os.path.basename(ela_path)) zipf.write(text_path, os.path.basename(text_path)) logger.info(f"Análisis completado. Archivo ZIP: {zip_path}") ela_rgb = cv2.cvtColor(ela_result, cv2.COLOR_BGR2RGB) return [zip_path, analysis_text, ela_rgb, google_maps_url or "", fig_bar, fig_pie, fig_box, fig_scatter] except Exception as e: logger.error(f"Error en procesamiento: {str(e)}") mensaje_error = f"❌ **ERROR CRÍTICO:** {str(e)}" error_img = np.zeros((300, 600, 3), dtype=np.uint8) cv2.putText(error_img, "ERROR INTERNO", (80, 160), cv2.FONT_HERSHEY_SIMPLEX, 1, (255, 255, 255), 2) return [None, mensaje_error, error_img, ""] + [None] * 4 # 🎨 Tema moderno theme = gr.themes.Soft(primary_hue="blue", secondary_hue="slate", neutral_hue="stone") # 🖥️ Interfaz Gradio with gr.Blocks(title="Análisis Forense de Imágenes con ELA", theme=theme, css=""" .orange-download-btn { background: linear-gradient(45deg, #FF6B35, #FF8E53) !important; color: white !important; border: none !important; font-weight: bold !important; } .orange-download-btn:hover { background: linear-gradient(45deg, #E55A2B, #FF7B42) !important; transform: scale(1.02) !important; box-shadow: 0 4px 12px rgba(255, 107, 53, 0.3) !important; } .equal-height { height: 400px !important; } """) as demo: gr.Markdown(""" # 📸 Análisis Forense de Imágenes (Error Level Analysis - ELA) **Programa de computación forense para analizar imágenes en busca de evidencia de manipulación o edición.** """) gr.Markdown("**Desarrollado por José R. Leonett para el Grupo de Peritos Forenses Digitales de Guatemala - [www.forensedigital.gt](https://www.forensedigital.gt)**") with gr.Row(): with gr.Column(): input_image = gr.Image( label="Subir imagen (JPG/PNG)", type="filepath", height=400, sources=["upload"], elem_classes=["equal-height"] ) process_btn = gr.Button("Analizar imagen", variant="primary") download_zip = gr.DownloadButton( label="⬇️ Descargar resultados (ZIP)", variant="secondary", visible=False, interactive=True, elem_classes=["orange-download-btn"] ) # Estadísticas en 2x2 grid with gr.Row(visible=False) as stats_row: with gr.Column(): stats_bar = gr.Plot(label="Porcentaje de Manipulación", show_label=True) stats_pie = gr.Plot(label="Nivel de Probabilidad", show_label=True) with gr.Column(): stats_box = gr.Plot(label="Distribución de Anomalías", show_label=True) stats_scatter = gr.Plot(label="Análisis de Componentes", show_label=True) with gr.Column(): with gr.Accordion(open=True): ela_image = gr.Image( label="ÁREAS TURQUESA = manipulaciones o borrados", type="numpy", height=400, show_label=True, elem_classes=["equal-height"] ) with gr.Accordion("Informe Detallado", open=True): analysis_text = gr.Textbox(label="📝 Resultados del análisis forense", lines=15, max_lines=25) google_maps_btn = gr.Button("📍 Ver ubicación en Google Maps", visible=False) google_maps_url_state = gr.State("") def reset_on_upload(): return ( gr.update(value=None), gr.update(value=None), gr.update(visible=False), gr.update(visible=False), "", gr.update(visible=False), None, None, None, None ) process_btn.click( fn=procesar_imagen, inputs=input_image, outputs=[download_zip, analysis_text, ela_image, google_maps_url_state, stats_bar, stats_pie, stats_box, stats_scatter], api_name="analyze_image" ).then( fn=lambda url: gr.update(visible=bool(url)), inputs=google_maps_url_state, outputs=google_maps_btn ).then( fn=lambda: gr.update(visible=True), inputs=None, outputs=download_zip ).then( fn=lambda: gr.update(visible=True), inputs=None, outputs=stats_row ) google_maps_btn.click( fn=lambda url: url, inputs=google_maps_url_state, outputs=None, js="(url) => { window.open(url, '_blank'); }" ) input_image.upload( fn=reset_on_upload, inputs=None, outputs=[analysis_text, ela_image, download_zip, google_maps_btn, google_maps_url_state, stats_row, stats_bar, stats_pie, stats_box, stats_scatter] ) # ▶️ Ejecución if __name__ == "__main__": demo.launch( server_name="0.0.0.0", server_port=7860, share=True, inbrowser=True, favicon_path="https://www.forensedigital.gt/wp-content/uploads/2019/07/cropped-40fb84a6-c75a-4c38-bfa0-c0a9777430cd-1.jpg" )