Spaces:
Sleeping
Sleeping
| """Ensemble fusion β combines DetectionResult objects into a final verdict. | |
| Strategy: | |
| 1. Filter: drop failed/errored detectors (set confidence = 0 proxy). | |
| 2. Bayesian log-odds combination weighted by detector weights Γ confidence. | |
| 3. Conflict detection: flag when high-confidence detectors strongly disagree. | |
| 4. Threshold: map final p_fake β REAL / UNCERTAIN / AI_GENERATED. | |
| Why Bayesian log-odds over simple weighted average: | |
| - A detector that says 0.99 should dominate more than one saying 0.6. | |
| - Log-odds is the correct way to combine independent probability estimates. | |
| - We still weight by (detector_weight Γ confidence) to account for | |
| detector reliability and self-reported uncertainty. | |
| """ | |
| from __future__ import annotations | |
| import math | |
| from typing import Sequence | |
| from backend.core.config import settings | |
| from backend.core.schema import DetectionResult, EnsembleResult, Verdict | |
| from backend.fusion.meta_learner import get_meta_learner | |
| # Weight per detector β mirrors config but looked up at fusion time. | |
| _WEIGHTS: dict[str, float] = { | |
| "hive": settings.weight_hive, | |
| "hive_vlm": settings.weight_hive_vlm, | |
| "aiornot": settings.weight_aiornot, | |
| "sightengine": settings.weight_sightengine, | |
| "claude_vision": settings.weight_claude, | |
| "mistral_vision": settings.weight_mistral, | |
| "gpt4_vision": settings.weight_gpt4, | |
| "gemini_vision": settings.weight_gemini, | |
| "gemma_vision": settings.weight_gemma_vision, | |
| "openrouter_gpt4": settings.weight_openrouter_gpt4, | |
| "openrouter_llama": settings.weight_openrouter_llama, | |
| "openrouter_mistral": settings.weight_openrouter_mistral, | |
| "local_forensics": settings.weight_local_forensics, | |
| "local_finetuned": settings.weight_local_finetuned, | |
| "hybrid_model": settings.weight_hybrid_model, | |
| # V3 detectors | |
| "frequency_domain": settings.weight_frequency_domain, | |
| "clip_unifd": settings.weight_clip_unifd, | |
| "dire": settings.weight_dire, | |
| "face_specialist": settings.weight_face_specialist, | |
| # V4 detectors | |
| "siglip2_aigc": settings.weight_siglip2_aigc, | |
| "npr": settings.weight_npr, | |
| } | |
| # Per-detector vote multiplier for the voting strategy. Sightengine is the | |
| # primary commercial signal, so its vote counts double. | |
| _VOTE_WEIGHTS: dict[str, float] = {"sightengine": 2.0} | |
| def _vote_weight(detector: str) -> float: | |
| return _VOTE_WEIGHTS.get(detector, 1.0) | |
| class EnsembleFusion: | |
| def fuse( | |
| self, | |
| results: Sequence[DetectionResult], | |
| processing_time_ms: float = 0.0, | |
| fusion_strategy_override: str | None = None, | |
| ) -> EnsembleResult: | |
| valid = [r for r in results if r.error is None and r.confidence > 0] | |
| failed = [r for r in results if r.error is not None or r.confidence == 0] | |
| if not valid: | |
| return EnsembleResult( | |
| verdict=Verdict.UNCERTAIN, | |
| p_fake=0.5, | |
| confidence=0.0, | |
| primary_evidence="All detectors failed or were unavailable", | |
| uncertainty_factors=[f"{r.detector}: {r.error}" for r in failed], | |
| detector_results=list(results), | |
| fusion_details={"method": "none"}, | |
| processing_time_ms=processing_time_ms, | |
| ) | |
| # Choose fusion strategy: meta-learner β per-request override β server default | |
| active_strategy = fusion_strategy_override or settings.fusion_strategy | |
| ml = get_meta_learner() if settings.use_meta_learner_fusion else None | |
| if ml is not None: | |
| p_fake, method_details = ml.predict(valid) | |
| elif active_strategy == "voting": | |
| p_fake, method_details = self._voting_fusion(valid) | |
| else: | |
| p_fake, method_details = self._bayesian_fusion(valid) | |
| verdict = _verdict_from_p_fake(p_fake, detector_results=valid) | |
| conflicts = self._detect_conflicts(valid) | |
| raw_conf = abs(p_fake - 0.5) * 2.0 | |
| conflict_penalty = 0.15 * len(conflicts) | |
| confidence = float(max(0.0, min(1.0, raw_conf - conflict_penalty))) | |
| primary_evidence, supporting = self._summarise_evidence(valid, verdict) | |
| uncertainty_factors = conflicts[:] | |
| if failed: | |
| uncertainty_factors.append( | |
| f"Unavailable detectors: {', '.join(r.detector for r in failed)}" | |
| ) | |
| # Extract diffusion_suspicion from local_forensics raw output | |
| diffusion_suspicion = 0.0 | |
| for r in results: | |
| if r.detector == "local_forensics" and r.raw: | |
| diffusion_suspicion = float(r.raw.get("diffusion_suspicion", 0.0)) | |
| break | |
| # Propagate generator name from whichever detector identified it | |
| generator = next( | |
| (r.generator for r in results if r.generator), | |
| None, | |
| ) | |
| return EnsembleResult( | |
| verdict=verdict, | |
| p_fake=p_fake, | |
| confidence=confidence, | |
| primary_evidence=primary_evidence, | |
| supporting_evidence=supporting, | |
| uncertainty_factors=uncertainty_factors, | |
| detector_results=list(results), | |
| fusion_details={ | |
| "method": "meta_learner" if ml else active_strategy, | |
| **method_details, | |
| "n_valid": len(valid), | |
| "n_failed": len(failed), | |
| }, | |
| processing_time_ms=processing_time_ms, | |
| diffusion_suspicion=diffusion_suspicion, | |
| generator=generator, | |
| ) | |
| # ββ Bayesian log-odds fusion ββββββββββββββββββββββββββββββββ | |
| def _bayesian_fusion( | |
| self, valid: list[DetectionResult] | |
| ) -> tuple[float, dict]: | |
| """ | |
| Combine independent P(fake) estimates via log-odds. | |
| log_odds_posterior = log_odds_prior + Ξ£ w_i * log_odds_i | |
| Prior: 0.5 (no prior knowledge). | |
| """ | |
| prior_log_odds = 0.0 | |
| total_weight = 0.0 | |
| weighted_log_odds = 0.0 | |
| per_detector: dict[str, dict] = {} | |
| for r in valid: | |
| w = _WEIGHTS.get(r.detector, 1.0) * float(r.confidence) | |
| # Dynamic weight adjustment based on generator detection | |
| # If Sightengine detected a specific generator, boost its weight (30% boost) | |
| if r.detector == "sightengine" and hasattr(r, "generator") and r.generator: | |
| w *= 1.3 # 30% confidence boost when generator explicitly detected | |
| weight_reason = f"generator detected ({r.generator})" | |
| else: | |
| weight_reason = "base weight" | |
| p = float(r.p_fake) | |
| p = max(0.01, min(0.99, p)) | |
| lo = math.log(p / (1.0 - p)) | |
| weighted_log_odds += w * lo | |
| total_weight += w | |
| per_detector[r.detector] = { | |
| "p_fake": r.p_fake, | |
| "confidence": r.confidence, | |
| "weight": _WEIGHTS.get(r.detector, 1.0), | |
| "effective_weight": w, | |
| "log_odds": lo, | |
| "weight_reason": weight_reason, | |
| "generator": getattr(r, "generator", None), | |
| } | |
| if total_weight == 0: | |
| return 0.5, {} | |
| avg_log_odds = prior_log_odds + (weighted_log_odds / total_weight) | |
| p_fake = 1.0 / (1.0 + math.exp(-avg_log_odds)) | |
| return float(p_fake), { | |
| "total_weight": total_weight, | |
| "avg_log_odds": avg_log_odds, | |
| "per_detector": per_detector, | |
| } | |
| # ββ Voting fusion βββββββββββββββββββββββββββββββββββββββββββ | |
| def _voting_fusion( | |
| self, valid: list[DetectionResult] | |
| ) -> tuple[float, dict]: | |
| """ | |
| Each detector casts one vote based on its own verdict threshold. | |
| p_fake is computed as fraction of AI votes, then sharpened so it | |
| actually crosses the ai_threshold / real_threshold. | |
| voting_threshold (default 0.5): fraction of votes needed to call AI. | |
| """ | |
| votes_ai = [r for r in valid if r.p_fake >= settings.ai_threshold] | |
| votes_real = [r for r in valid if r.p_fake <= settings.real_threshold] | |
| votes_uncertain = [ | |
| r for r in valid | |
| if settings.real_threshold < r.p_fake < settings.ai_threshold | |
| ] | |
| # Votes are weighted per detector (Sightengine counts double). | |
| total_votes = sum(_vote_weight(r.detector) for r in valid) | |
| ai_votes = sum(_vote_weight(r.detector) for r in votes_ai) | |
| real_votes = sum(_vote_weight(r.detector) for r in votes_real) | |
| ai_fraction = ai_votes / total_votes | |
| # Map fraction β p_fake in a way that crosses thresholds cleanly: | |
| # If ai_fraction >= voting_threshold β p_fake just above ai_threshold | |
| # If ai_fraction == 0 β p_fake just below real_threshold | |
| # Otherwise β UNCERTAIN midpoint | |
| if ai_fraction >= settings.voting_threshold: | |
| # Scale within [ai_threshold, 1.0] based on how overwhelming the vote is | |
| p_fake = settings.ai_threshold + (1.0 - settings.ai_threshold) * ai_fraction | |
| elif real_votes / total_votes >= settings.voting_threshold: | |
| real_fraction = real_votes / total_votes | |
| p_fake = settings.real_threshold * (1.0 - real_fraction) | |
| else: | |
| # Split vote β UNCERTAIN: vote-weighted average of individual p_fakes | |
| p_fake = sum(_vote_weight(r.detector) * r.p_fake for r in valid) / total_votes | |
| return float(p_fake), { | |
| "votes_ai": [r.detector for r in votes_ai], | |
| "votes_real": [r.detector for r in votes_real], | |
| "votes_uncertain": [r.detector for r in votes_uncertain], | |
| "ai_fraction": ai_fraction, | |
| "voting_threshold": settings.voting_threshold, | |
| } | |
| # ββ Conflict detection ββββββββββββββββββββββββββββββββββββββ | |
| def _detect_conflicts(self, valid: list[DetectionResult]) -> list[str]: | |
| """Flag when high-confidence detectors strongly disagree. | |
| Also flags significant disagreement between hybrid model versions | |
| (latest vs backup) which may indicate uncertain/ambiguous images. | |
| """ | |
| conflicts: list[str] = [] | |
| high_conf = [r for r in valid if r.confidence >= 0.6] | |
| if len(high_conf) < 2: | |
| return conflicts | |
| ai_detectors = [r for r in high_conf if r.verdict == Verdict.AI_GENERATED] | |
| real_detectors = [r for r in high_conf if r.verdict == Verdict.REAL] | |
| if ai_detectors and real_detectors: | |
| ai_names = ", ".join(r.detector for r in ai_detectors) | |
| real_names = ", ".join(r.detector for r in real_detectors) | |
| conflicts.append( | |
| f"Detector disagreement: [{ai_names}] β AI vs [{real_names}] β REAL" | |
| ) | |
| # Check for hybrid model version disagreement (both are "hybrid_model" detector) | |
| # This catches cases where latest and backup models give conflicting signals | |
| hybrid_results = [r for r in valid if r.detector == "hybrid_model"] | |
| if len(hybrid_results) >= 2: | |
| # Compare p_fake values across model versions | |
| p_fakes = [float(r.p_fake) for r in hybrid_results] | |
| p_fake_spread = max(p_fakes) - min(p_fakes) | |
| # If models differ by >0.25 in p_fake, flag as uncertainty signal | |
| # e.g., latest=0.48, backup=0.34 β spread=0.14 (not flagged) | |
| # e.g., latest=0.48, backup=0.65 β spread=0.17 (not flagged, both >0.3) | |
| # But if verdicts differ (one AI, one REAL), flag even with smaller spread | |
| verdicts = [r.verdict for r in hybrid_results] | |
| if len(set(verdicts)) > 1: # Different verdicts | |
| conflicts.append( | |
| f"Hybrid model version disagreement: " | |
| f"{hybrid_results[0].detector} versions give conflicting signals " | |
| f"(p_fake range: {min(p_fakes):.2f}-{max(p_fakes):.2f})" | |
| ) | |
| return conflicts | |
| # ββ Evidence summary ββββββββββββββββββββββββββββββββββββββββ | |
| def _summarise_evidence( | |
| self, valid: list[DetectionResult], verdict: Verdict | |
| ) -> tuple[str, list[str]]: | |
| if verdict == Verdict.AI_GENERATED: | |
| ranked = sorted(valid, key=lambda r: r.p_fake, reverse=True) | |
| elif verdict == Verdict.REAL: | |
| ranked = sorted(valid, key=lambda r: r.p_fake) | |
| else: | |
| ranked = sorted(valid, key=lambda r: r.confidence, reverse=True) | |
| primary = "" | |
| supporting: list[str] = [] | |
| for r in ranked: | |
| if not r.evidence: | |
| continue | |
| first = r.evidence[0] | |
| # Make verdict more explicit in evidence summary | |
| if verdict == Verdict.REAL and r.verdict == Verdict.REAL: | |
| summary = f"[{r.detector}] Real image: {(1-r.p_fake)*100:.1f}% confidence" | |
| elif verdict == Verdict.AI_GENERATED and r.verdict == Verdict.AI_GENERATED: | |
| summary = f"[{r.detector}] AI-generated: {r.p_fake*100:.1f}% confidence" | |
| elif verdict == Verdict.MANIPULATED_DEEPFAKE and r.verdict == Verdict.MANIPULATED_DEEPFAKE: | |
| summary = f"[{r.detector}] Face manipulation detected: {r.p_fake*100:.1f}% confidence" | |
| else: | |
| summary = f"[{r.detector}] {first}" | |
| if not primary: | |
| primary = summary | |
| else: | |
| supporting.append(summary) | |
| if len(supporting) >= 4: | |
| break | |
| if not primary: | |
| primary = f"Ensemble confidence: {(1-valid[0].p_fake)*100:.1f}% real from {len(valid)} detectors" | |
| return primary, supporting | |
| def _verdict_from_p_fake( | |
| p_fake: float, | |
| detector_results: list | None = None, | |
| ) -> Verdict: | |
| """ | |
| Map ensemble p_fake to a verdict. | |
| If p_fake crosses the AI threshold AND the hybrid_model detector specifically | |
| predicted MANIPULATED_DEEPFAKE with high confidence, return that more specific | |
| verdict instead of the generic AI_GENERATED. | |
| """ | |
| if p_fake >= settings.ai_threshold: | |
| # Check if the V1 model specifically identified a deepfake manipulation | |
| if detector_results: | |
| hybrid = next( | |
| (r for r in detector_results | |
| if r.detector == "hybrid_model" and r.error is None | |
| and r.verdict == Verdict.MANIPULATED_DEEPFAKE | |
| and r.confidence >= 0.5), | |
| None, | |
| ) | |
| if hybrid is not None: | |
| return Verdict.MANIPULATED_DEEPFAKE | |
| return Verdict.AI_GENERATED | |
| if p_fake <= settings.real_threshold: | |
| return Verdict.REAL | |
| return Verdict.UNCERTAIN | |