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" )