""" app.py — Interface Streamlit do sistema forense de deteção de deepfakes. Responsabilidades deste ficheiro: - Carregar modelos (SwinV2, CLIP DF-40) via cache - Gerir a UI (upload, sliders, botões, visualizações) - Orquestrar o pipeline de inferência chamando funções dos módulos especializados NÃO duplica lógica de: explainability.py → generate_heatmap, get_region_masks, score_regions_manipulation, get_landmarker (singleton FaceLandmarker), build_face_mask artifact_zones.py → extract_artifact_zones, segment_zones_with_probability interacao_LVM.py → ForensicVLMOrchestrator models/models.py → SwinV2Classifier, DF40CLIPModel, transforms config.py → FUSION_WEIGHTS, ROOT_DIR, DEVICE """ import base64 import concurrent.futures import json import warnings import gc from io import BytesIO import cv2 import mediapipe as mp # apenas para mp.Image / mp.ImageFormat import numpy as np import streamlit as st import streamlit.components.v1 as components import torch import transformers from huggingface_hub import hf_hub_download from PIL import Image # ── Módulos do projecto ──────────────────────────────────────────────── from artifact_zones import extract_artifact_zones, segment_zones_with_probability from config import DEVICE, FUSION_WEIGHTS, ROOT_DIR from explainability import ( generate_heatmap, get_landmarker, # singleton reutilizado aqui e no explainability.py get_region_masks, score_regions_manipulation, ) from models.models import DF40CLIPModel, SwinV2Classifier, get_clip_transform, get_swinv2_transform from scripts.interacao_LVM import ForensicVLMOrchestrator warnings.filterwarnings("ignore", category=UserWarning, message=".*sm_120.*") transformers.logging.set_verbosity_error() PADDING_FACE = 0.45 st.set_page_config( page_title="Segurança Visual", layout="wide", initial_sidebar_state="expanded" ) # ══════════════════════════════════════════════════════════════════════ # CACHE DE RECURSOS — carregados uma única vez por sessão # ══════════════════════════════════════════════════════════════════════ @st.cache_resource def get_all_models(): """Carrega SwinV2 e CLIP DF-40 a partir do HuggingFace Hub (cache local).""" # SwinV2 swin_path = hf_hub_download(repo_id="liamu/Deepfake-Pesos", filename="model.safetensors") swin = SwinV2Classifier(ckpt_path=swin_path).to("cpu").eval() # CLIP DF-40 — higienização do state_dict para remover prefixos de DataParallel clip_path = hf_hub_download(repo_id="liamu/Deepfake-Pesos", filename="clip_large.pth") state = torch.load(clip_path, map_location="cpu") cleaned = {} for k, v in state.items(): nk = k.replace("module.", "") if k.startswith("module.") else k if nk.startswith("backbone.") and not nk.startswith("backbone.vision_model."): nk = nk.replace("backbone.", "backbone.vision_model.", 1) cleaned[nk] = v clip = DF40CLIPModel(num_labels=2).to("cpu") clip.load_state_dict(cleaned) clip.eval() return swin, clip @st.cache_resource def load_fusion_weights(): """Lê o JSON de pesos da Regressão Logística uma única vez.""" try: with open(FUSION_WEIGHTS) as f: cfg = json.load(f) return ( float(cfg.get("weight_swin", 1.0)), float(cfg.get("weight_df40", 1.0)), float(cfg.get("weight_z", 1.0)), float(cfg.get("bias", 0.0)), float(cfg.get("threshold_optimal", 0.6877)), ) except Exception: return 1.0, 1.0, 1.0, 0.0, 0.6877 @st.cache_resource def get_transforms(): """Cria os transforms de pré-processamento uma única vez.""" return get_swinv2_transform(), get_clip_transform() # ══════════════════════════════════════════════════════════════════════ # DETEÇÃO FACIAL — reutiliza o singleton get_landmarker() do explainability.py # ══════════════════════════════════════════════════════════════════════ def extract_main_face(img_bgr, padding_ratio=PADDING_FACE): """ Recorta a face dominante com padding adaptativo. Usa o FaceLandmarker (MediaPipe Tasks) já instanciado em explainability.py. """ img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB) h, w = img_bgr.shape[:2] try: mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=img_rgb) result = get_landmarker().detect(mp_image) except Exception as e: return None, f"Erro na deteção facial: {e}" if not result.face_landmarks: return None, "Nenhuma face detetada. Submeta um retrato mais claro." lm = result.face_landmarks[0] xs = [int(p.x * w) for p in lm] ys = [int(p.y * h) for p in lm] x_min, x_max = min(xs), max(xs) y_min, y_max = min(ys), max(ys) pad_h = int((y_max - y_min) * padding_ratio) pad_w = int((x_max - x_min) * padding_ratio) y1 = max(0, y_min - pad_h); y2 = min(h, y_max + pad_h) x1 = max(0, x_min - pad_w); x2 = min(w, x_max + pad_w) return img_bgr[y1:y2, x1:x2], "OK" # ══════════════════════════════════════════════════════════════════════ # FUNÇÕES DE RENDERIZAÇÃO UI (específicas do Streamlit — não duplicar noutros módulos) # ══════════════════════════════════════════════════════════════════════ def inject_custom_css(): st.markdown(""" """, unsafe_allow_html=True) def render_confidence_bar(prob_fake, threshold): is_fake = prob_fake > threshold confianca = prob_fake if is_fake else (1.0 - prob_fake) color = "#ef4444" if is_fake else "#22c55e" label = "FALSA" if is_fake else "REAL" st.markdown(f"""
🎯 {label} {confianca*100:.1f}%
""", unsafe_allow_html=True) def visualize_heatmap(heatmap_array, colormap=cv2.COLORMAP_JET): hm = cv2.applyColorMap((heatmap_array * 255).astype(np.uint8), colormap) return Image.fromarray(cv2.cvtColor(hm, cv2.COLOR_BGR2RGB)) def render_heat_card(img_pil, title, subtitle, color): buff = BytesIO() img_pil.save(buff, format="PNG") b64 = base64.b64encode(buff.getvalue()).decode("utf-8") return f"""

{title}
{subtitle}

""" def render_interactive_polygons(img_pil, zones, prob_masks): buff = BytesIO() img_pil.save(buff, format="JPEG") img_b64 = base64.b64encode(buff.getvalue()).decode("utf-8") width, height = img_pil.size def san(name): return name.lower().replace(" ", "-").replace("/", "-") svg_polygons = "" menu_items = "" valid_zones = [] for zone in zones: z_name = zone.name.lower() mask = prob_masks.get(z_name) if mask is None or not mask.any(): continue valid_zones.append(z_name) zc = san(z_name) # ── Redimensionar máscara de 512×512 para o espaço da imagem exibida ── mask_u8 = cv2.resize( mask.astype(np.uint8) * 255, (width, height), # dimensões reais da imagem no SVG interpolation=cv2.INTER_NEAREST ) cnts, _ = cv2.findContours(mask_u8, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) for cnt in cnts: approx = cv2.approxPolyDP(cnt, 0.002 * cv2.arcLength(cnt, True), True) pts = " ".join(f"{pt[0][0]},{pt[0][1]}" for pt in approx) svg_polygons += f'' for name in sorted(set(valid_zones)): zc = san(name) hi = f"document.querySelectorAll('.poly-{zc}').forEach(p=>{{p.style.fill='rgba(239,68,68,.7)';p.style.stroke='rgba(255,255,255,1)';p.style.strokeWidth='3';}});this.style.backgroundColor='#3b82f6';this.style.color='white';" ho = f"document.querySelectorAll('.poly-{zc}').forEach(p=>{{p.style.fill='rgba(239,68,68,.15)';p.style.stroke='rgba(255,255,255,.2)';p.style.strokeWidth='1';}});this.style.backgroundColor='#1e293b';this.style.color='#cbd5e1';" menu_items += f'
{name}
' components.html(f"""

Anatomia Afetada

{menu_items}
{svg_polygons}
""", height=500) # ══════════════════════════════════════════════════════════════════════ # MAIN # ══════════════════════════════════════════════════════════════════════ def main(): inject_custom_css() # Carregar todos os recursos em cache swin, clip_df40 = get_all_models() w_swin, w_df40, w_z, bias, threshold_default = load_fusion_weights() swin_tf, clip_tf = get_transforms() st.markdown( '

🔍 Deepfake Face Detector

', unsafe_allow_html=True) st.markdown("

Deteção de criação sintética e manipulação em rostos humanos.

", unsafe_allow_html=True) with st.expander("Como funciona a plataforma", expanded=False): st.markdown(""" O sistema analisa a imagem em três fases para determinar se foi gerada ou manipulada por Inteligência Artificial: 1. **Análise de Superfície:** Procura artefactos microscópicos e falhas de textura invisíveis ao olho humano. 2. **Coerência Biométrica:** Verifica se os traços faciais e a iluminação são consistentes, detetando trocas de rosto, edições. 3. **Localização de Anomalias:** Isola e mapeia graficamente as áreas específicas onde a manipulação ocorreu.

INPUT: Imagem RGB da cara (A cores)
OUTPUT: Classificação → Explicação Visual (Explicador e Segmentador) → Relatório do Gemini

""", unsafe_allow_html=True) # ── Sidebar ────────────────────────────────────────────────────── st.sidebar.markdown("### ⚙️ Configuração da Análise") opcoes_input = ["Sua Imagem", "Exemplo Falso", "Exemplo Real"] escolha_input = st.sidebar.selectbox("Fonte da imagem:", opcoes_input) threshold = st.sidebar.slider("Rigor da Deteção", 0.10, 0.95, float(threshold_default), 0.01) st.sidebar.markdown("---") st.sidebar.markdown("""

Em que consiste a APP

1. Deteção

SwinV2 & CLIP DF-40
Analisam texturas microscópicas e traços faciais para calcular a probabilidade de a imagem ser falsa.

2. Mapeamento

CLIP Surgery & BiSeNet
Funcionam como um raio-X, isolando e destacando as zonas exatas do rosto que sofreram manipulação.

3. Relatório

Gemini (Google)
Lê as anomalias detetadas nos passos anteriores e gera uma explicação consoante seja falsa ou real.

""", unsafe_allow_html=True) col_input, col_result = st.columns([1, 1.2], gap="large") img_bgr = None raw_img_bgr = None # ── Coluna de Input ─────────────────────────────────────────────── with col_input: st.markdown("#### Origem da Imagem") if escolha_input == "Sua Imagem": up = st.file_uploader("Arraste o ficheiro", type=["jpg", "png", "jpeg"], label_visibility="collapsed") if up: raw_img_bgr = cv2.imdecode(np.frombuffer(up.read(), np.uint8), cv2.IMREAD_COLOR) else: nome_base = "false" if "Falso" in escolha_input else "real" for ext in [".png", ".jpg", ".jpeg"]: p = ROOT_DIR / "exemplos" / f"{nome_base}{ext}" if p.exists(): raw_img_bgr = cv2.imread(str(p)) break analisar = False if raw_img_bgr is not None: with st.spinner("A detetar rosto na imagem..."): cropped, status = extract_main_face(raw_img_bgr) if cropped is None: st.error(status) else: img_bgr = cropped st.image(cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB), caption="Área de análise isolada", width=350) label_btn = "Verificar Autenticidade" if escolha_input == "Sua Imagem" \ else f"Analisar Exemplo ({nome_base.upper()})" analisar = st.button(label_btn, use_container_width=True) # ── Coluna de Resultados ───────────────────────────────────────── with col_result: st.markdown("#### 📝 Resultados da Análise") if analisar and img_bgr is not None: # Limpar estado de análise anterior for key in ["contrastive_hm", "per_text_hm", "prompt_list", "reg_scores"]: st.session_state.pop(key, None) img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB) img_hires = cv2.resize(img_rgb, (512, 512)) img_pil = Image.fromarray(img_rgb) raw_rgb = cv2.cvtColor(raw_img_bgr, cv2.COLOR_BGR2RGB) raw_pil = Image.fromarray(raw_rgb) # Preparar tensores t_swin = swin_tf(raw_pil).unsqueeze(0).to("cpu") t_clip = (clip_tf(img_pil).unsqueeze(0).to("cpu") .type(next(clip_df40.parameters()).dtype)) # Inferência paralela dos três especialistas with st.spinner("A verificar autenticidade..."): with concurrent.futures.ThreadPoolExecutor(max_workers=3) as ex: f_swin = ex.submit(lambda t: float(torch.softmax(swin(t), dim=1)[0, 1].item()), t_swin) f_clip = ex.submit(lambda t: float(torch.softmax(clip_df40(t), dim=1)[0, 1].item()), t_clip) f_surgery = ex.submit(generate_heatmap, img_hires) prob_swin = f_swin.result() prob_clip_df40 = f_clip.result() contrastive_hm, per_text_hm, scores, prompts, _ = f_surgery.result() # Z-score espacial masks = get_region_masks(img_hires) # # 512×512 — mesma resolução do heatmap contrast_map = np.clip( per_text_hm.get("AI face manipulation", np.zeros((512, 512))) - per_text_hm.get("real human face", np.zeros((512, 512))), 0, 1 ) reg_scores = score_regions_manipulation(img_hires, contrast_map, masks, scores) contrasts = [d["contrast"] for d in reg_scores.values()] z_anomaly = ((max(contrasts) - np.mean(contrasts)) / (np.std(contrasts) + 1e-6) if len(contrasts) > 1 else 0.0) # Fusão LR + High-Confidence Override logit = prob_swin * w_swin + prob_clip_df40 * w_df40 + z_anomaly * w_z + bias prob_final = float(1.0 / (1.0 + np.exp(-logit))) if max(prob_swin, prob_clip_df40) > 0.85: prob_final = max(prob_final, max(prob_swin, prob_clip_df40)) is_fake = prob_final > threshold # Guardar no session_state para o painel técnico st.session_state.update({ "contrastive_hm": contrastive_hm, "per_text_hm": per_text_hm, "prompt_list": prompts, "reg_scores": reg_scores, }) render_confidence_bar(prob_final, threshold) if is_fake: zones = extract_artifact_zones(img_hires, contrastive_hm, masks, reg_scores) if zones: prob_masks = segment_zones_with_probability(contrastive_hm, zones, prob_threshold=0.40) render_interactive_polygons(Image.fromarray(img_rgb), zones, prob_masks) else: st.image(img_rgb, width=250, caption="Nenhuma anomalia detetada.") # ── Relatório ──────────────────────── if analisar and img_bgr is not None and "is_fake" in dir() and not is_fake: st.markdown("
", unsafe_allow_html=True) st.markdown("### 📋 Relatório") with st.spinner("A gerar relatório ..."): orchestrator = ForensicVLMOrchestrator(mode="api") stream = orchestrator.generate_real_justification(img_rgb, prob_final) ESTILO_REAL = ("background-color:#0f172a;padding:25px;border-radius:8px;" "border-left:4px solid #22c55e;font-size:15px;color:#f8fafc;" "line-height:1.7;box-shadow:0 4px 6px rgba(0,0,0,.2);margin-bottom:2rem;") box_real = st.empty() if isinstance(stream, str): box_real.markdown(f"
{stream}
", unsafe_allow_html=True) else: acumulado = "" for chunk in stream: if chunk: acumulado += chunk box_real.markdown(f"
{acumulado} ▌
", unsafe_allow_html=True) box_real.markdown(f"
{acumulado}
", unsafe_allow_html=True) # ── Relatório ) ────────── if analisar and img_bgr is not None and "is_fake" in dir() and is_fake and "zones" in dir() and zones: st.markdown("
", unsafe_allow_html=True) st.markdown("### 📋 Relatório ") with st.spinner("A gerar relatório ..."): orchestrator = ForensicVLMOrchestrator(mode="api") global_bbox = ( min(z.bbox[0] for z in zones), min(z.bbox[1] for z in zones), max(z.bbox[2] for z in zones), max(z.bbox[3] for z in zones), ) stream = orchestrator.generate_justification( img_rgb=img_hires, prob_final=prob_final, prob_swin=prob_swin, prob_clip=prob_clip_df40, zone_name=", ".join(z.name for z in zones), bbox=global_bbox, ) ESTILO = ("background-color:#0f172a;padding:25px;border-radius:8px;" "border-left:4px solid #FF0000;font-size:15px;color:#f8fafc;" "line-height:1.7;box-shadow:0 4px 6px rgba(0,0,0,.2);margin-bottom:2rem;") box = st.empty() if isinstance(stream, str): box.markdown(f"
{stream}
", unsafe_allow_html=True) else: acumulado = "" for chunk in stream: if chunk: acumulado += chunk box.markdown(f"
{acumulado} ▌
", unsafe_allow_html=True) box.markdown(f"
{acumulado}
", unsafe_allow_html=True) # ── Painel Técnico: Matemática Contrastiva ──────────────────────── if analisar and is_fake and st.session_state.get("contrastive_hm") is not None: with st.expander("Visão Detalhada: Como a Anomalia é Isolada", expanded=False): st.markdown("### Processo de Subtração Visual") st.markdown("O sistema analisa a imagem através de duas 'lentes' diferentes: uma programada para detetar sinais de manipulação gerada por IA e outra para reconhecer padrões orgânicos de um rosto humano natural. Ao subtrair a componente natural, o ruído visual desaparece, destacando apenas as áreas manipuladas.") st.markdown("
", unsafe_allow_html=True) top_prompt = "AI face manipulation" real_prompt = "real human face" c1, cm, c2, ce, c3 = st.columns([1.5, .3, 1.5, .3, 1.5], vertical_alignment="center") with c1: st.markdown(render_heat_card( visualize_heatmap(st.session_state["per_text_hm"][top_prompt]), "Padrão Sintético", "Lente de Manipulação", "#ef4444"), unsafe_allow_html=True) with cm: st.markdown("

-

", unsafe_allow_html=True) with c2: if real_prompt in st.session_state["per_text_hm"]: st.markdown(render_heat_card( visualize_heatmap(st.session_state["per_text_hm"][real_prompt]), "Padrão Orgânico", "Lente Natural", "#22c55e"), unsafe_allow_html=True) with ce: st.markdown("

=

", unsafe_allow_html=True) with c3: st.markdown(render_heat_card( visualize_heatmap(st.session_state["contrastive_hm"]), "Resultado Final", "Anomalia Destacada", "#3b82f6"), unsafe_allow_html=True) if __name__ == "__main__": main()