| 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 |
|
|
| |
| logging.basicConfig(level=logging.INFO) |
| logger = logging.getLogger(__name__) |
|
|
| |
| 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) |
|
|
| |
| 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.") |
|
|
| |
| diferencia = cv2.absdiff(img_cv.astype(np.float32), img_comprimida.astype(np.float32)) |
| scaled_diff = np.clip(diferencia * 15, 0, 255).astype(np.uint8) |
|
|
| |
| gray_diff = cv2.cvtColor(scaled_diff, cv2.COLOR_BGR2GRAY) |
| _, mask = cv2.threshold(gray_diff, 5, 255, cv2.THRESH_BINARY) |
|
|
| |
| ela_color = cv2.applyColorMap(gray_diff, cv2.COLORMAP_TURBO) |
|
|
| |
| 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""" |
| |
| |
| 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="<b>Porcentaje de Áreas Manipuladas</b>", x=0.5), |
| yaxis_title="Porcentaje (%)", |
| height=250, |
| showlegend=False, |
| margin=dict(l=20, r=20, t=40, b=20) |
| ) |
| |
| |
| 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="<b>Nivel de Probabilidad</b>", x=0.5), |
| height=250, |
| showlegend=False, |
| margin=dict(l=20, r=20, t=40, b=20) |
| ) |
| |
| |
| 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="<b>Distribución de Anomalías</b>", x=0.5), |
| yaxis_title="Porcentaje (%)", |
| height=250, |
| showlegend=False, |
| margin=dict(l=20, r=20, t=40, b=20) |
| ) |
| |
| |
| 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="<b>Análisis de Componentes</b>", 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) |
|
|
| |
| 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 |
|
|
| |
| theme = gr.themes.Soft(primary_hue="blue", secondary_hue="slate", neutral_hue="stone") |
|
|
| |
| 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"] |
| ) |
|
|
| |
| 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] |
| ) |
|
|
| |
| 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" |
| ) |