Spaces:
Sleeping
Sleeping
| #!/usr/bin/env python3 | |
| """ | |
| SpectralDetector - End-to-End Image Classification | |
| ================================================== | |
| Script completo per classificare nuove immagini usando il sistema V3.5. | |
| Workflow: | |
| 1. Carica immagine RAW | |
| 2. Estrae features spettrali (FFT, DCT, compressione, ecc.) | |
| 3. HEAD A: REAL vs AI classification (p_real) | |
| 4. HEAD D: GPT-IMAGE-1 detection (head_d_score) | |
| 5. Meta-Router V3.5: Decisione finale con soft-veto | |
| 6. Output: REAL / AI (con generator type se disponibile) | |
| Usage: | |
| python detect_image.py path/to/image.jpg | |
| python detect_image.py path/to/image.jpg --verbose | |
| python detect_image.py path/to/image.jpg --save-features features.json | |
| Author: Denis Billi | |
| Date: November 6, 2025 | |
| Version: 1.0 (V3.5 Production) | |
| """ | |
| import sys | |
| import os | |
| import json | |
| import argparse | |
| import time | |
| from pathlib import Path | |
| from typing import Dict, Tuple, Optional | |
| import warnings | |
| warnings.filterwarnings('ignore') | |
| # Add project root and src to path | |
| project_root = Path(__file__).parent | |
| sys.path.insert(0, str(project_root)) | |
| sys.path.insert(0, str(project_root / 'src')) | |
| import cv2 | |
| import numpy as np | |
| import pandas as pd | |
| # Import joblib from archive/legacy_src if needed | |
| try: | |
| import joblib | |
| except ImportError: | |
| sys.path.insert(0, str(project_root / 'archive' / 'legacy_src')) | |
| import joblib | |
| # Import feature extractor | |
| try: | |
| # Try from archive/legacy_src (where it actually is) | |
| legacy_src_path = project_root / 'archive' / 'legacy_src' | |
| if legacy_src_path.exists(): | |
| sys.path.insert(0, str(legacy_src_path)) | |
| from fft_features import FFTFeatureExtractor | |
| except ImportError as e: | |
| raise ImportError(f"Cannot find fft_features module: {e}. Make sure archive/legacy_src/ exists.") | |
| class SpectralDetectorV35: | |
| """ | |
| End-to-end detector for REAL vs AI image classification. | |
| Architecture: | |
| - HEAD A: REAL vs AI binary classification (p_real) | |
| - HEAD D: GPT-IMAGE-1 vs other AI generators (head_d_score) | |
| - Meta-Router V3.5: Logistic regression with soft-veto | |
| - Isotonic calibration for both HEAD A and HEAD D | |
| """ | |
| VERSION = "3.5-PRODUCTION" | |
| BUILD_DATE = "2025-11-06" | |
| # Default model paths (production deployment) | |
| DEFAULT_MODELS = { | |
| 'head_a': 'outputs/hierarchical_pipeline/head_a_model_20251023_091017.pkl', | |
| 'head_d': 'outputs/hierarchical_pipeline/head_d_model_20251023_091017.pkl', # Corrotto, ma proviamo | |
| 'meta_router': 'outputs/hybrid_soft_veto_v35/SpectralDetector_V3_5_PROD.pkl', # Corrotto, fallback | |
| 'v35_alternative': 'outputs/soft_veto_robust/soft_veto_robust_C0.1_20251105_141740.pkl' | |
| } | |
| def __init__( | |
| self, | |
| head_a_path: Optional[str] = None, | |
| head_d_path: Optional[str] = None, | |
| meta_router_path: Optional[str] = None, | |
| verbose: bool = False | |
| ): | |
| """ | |
| Initialize detector. | |
| Args: | |
| head_a_path: Path to HEAD A model (.pkl) | |
| head_d_path: Path to HEAD D model (.pkl) | |
| meta_router_path: Path to Meta-Router V3.5 model (.pkl) | |
| verbose: Enable verbose logging | |
| """ | |
| self.verbose = verbose | |
| # Feature extractor configuration (aligned with training) | |
| self.feature_extractor = FFTFeatureExtractor( | |
| target_size=1024, | |
| grayscale=True, | |
| saturation_features=True, | |
| chroma_features=False, | |
| exclude_jpeg_freqs=True | |
| ) | |
| # Load models | |
| self.head_a = self._load_head_a(head_a_path or self.DEFAULT_MODELS['head_a']) | |
| self.head_d = self._load_head_d(head_d_path or self.DEFAULT_MODELS['head_d']) | |
| self.meta_router = self._load_meta_router( | |
| meta_router_path or self.DEFAULT_MODELS['meta_router'], | |
| fallback=self.DEFAULT_MODELS['v35_alternative'] | |
| ) | |
| # Decision thresholds (from V3.5 production config) | |
| self.threshold_real_high = 0.42 # REAL decision threshold | |
| self.threshold_real_low = 0.28 # Gray zone lower bound | |
| self.threshold_ai = 0.5 # P(AI) decision threshold (soft-veto) | |
| # Backward compatibility aliases (for old meta_router pickle files) | |
| self.tau_real_high = self.threshold_real_high | |
| self.tau_real_low = self.threshold_real_low | |
| self.tau_ai = self.threshold_ai | |
| self.p_ai_threshold = self.threshold_ai # Alternative name for threshold_ai | |
| if self.verbose: | |
| self._print_config() | |
| def _log(self, message: str): | |
| """Print if verbose enabled""" | |
| if self.verbose: | |
| # Fix Windows encoding issues with emoji | |
| try: | |
| print(message) | |
| except UnicodeEncodeError: | |
| # Remove emoji and retry | |
| message_clean = message.encode('ascii', 'ignore').decode('ascii') | |
| print(message_clean) | |
| def _load_head_a(self, model_path: str) -> Optional[Dict]: | |
| """Load HEAD A model""" | |
| if not os.path.exists(model_path): | |
| self._log(f" HEAD A not found: {model_path}") | |
| return None | |
| try: | |
| # Load model with joblib | |
| model = joblib.load(model_path) | |
| # Verify model size (corrotti se < 10KB) | |
| file_size = os.path.getsize(model_path) | |
| if file_size < 10000: | |
| self._log(f" HEAD A model corrupted ({file_size} bytes)") | |
| return None | |
| # Load feature names from separate file (if exists) | |
| model_dir = Path(model_path).parent | |
| feature_names_path = model_dir / 'feature_names.txt' | |
| feature_names = None | |
| if feature_names_path.exists(): | |
| with open(feature_names_path, 'r') as f: | |
| feature_names = [line.strip() for line in f if line.strip()] | |
| self._log(f" Loaded {len(feature_names)} feature names") | |
| # Load scaler if exists | |
| scaler_path = model_dir / 'head_a_scaler.pkl' | |
| scaler = None | |
| if scaler_path.exists(): | |
| scaler = joblib.load(scaler_path) | |
| self._log(f" Loaded scaler") | |
| self._log(f" HEAD A loaded: {model_path} ({file_size:,} bytes)") | |
| return { | |
| 'model': model, | |
| 'scaler': scaler, | |
| 'feature_names': feature_names | |
| } | |
| except Exception as e: | |
| self._log(f" HEAD A loading failed: {e}") | |
| return None | |
| def _load_head_d(self, model_path: str) -> Optional[Dict]: | |
| """Load HEAD D model""" | |
| if not os.path.exists(model_path): | |
| self._log(f" HEAD D not found: {model_path}") | |
| return None | |
| try: | |
| # Load model with joblib | |
| model = joblib.load(model_path) | |
| file_size = os.path.getsize(model_path) | |
| if file_size < 10000: | |
| self._log(f" HEAD D model corrupted ({file_size} bytes)") | |
| return None | |
| # Load feature names from separate file (if exists) | |
| model_dir = Path(model_path).parent | |
| feature_names_path = model_dir / 'feature_names.txt' | |
| feature_names = None | |
| if feature_names_path.exists(): | |
| with open(feature_names_path, 'r') as f: | |
| feature_names = [line.strip() for line in f if line.strip()] | |
| # Load scaler if exists | |
| scaler_path = model_dir / 'head_d_scaler.pkl' | |
| scaler = None | |
| if scaler_path.exists(): | |
| scaler = joblib.load(scaler_path) | |
| self._log(f" HEAD D loaded: {model_path} ({file_size:,} bytes)") | |
| return { | |
| 'model': model, | |
| 'scaler': scaler, | |
| 'feature_names': feature_names | |
| } | |
| except Exception as e: | |
| self._log(f" HEAD D loading failed: {e}") | |
| return None | |
| def _load_meta_router(self, model_path: str, fallback: str) -> Optional[Dict]: | |
| """Load Meta-Router V3.5""" | |
| # Try primary path | |
| if os.path.exists(model_path): | |
| try: | |
| with open(model_path, 'rb') as f: | |
| meta = joblib.load(f) | |
| file_size = os.path.getsize(model_path) | |
| if file_size < 1000: # Changed from 10000 to 1000 (meta-router is small) | |
| self._log(f"⚠️ Meta-Router corrupted ({file_size} bytes), trying fallback...") | |
| else: | |
| self._log(f"✅ Meta-Router loaded: {model_path}") | |
| return meta | |
| except Exception as e: | |
| self._log(f"⚠️ Meta-Router loading failed: {e}, trying fallback...") | |
| # Try fallback | |
| if os.path.exists(fallback): | |
| try: | |
| with open(fallback, 'rb') as f: | |
| meta = joblib.load(f) | |
| self._log(f"✅ Meta-Router loaded (fallback): {fallback}") | |
| return meta | |
| except Exception as e: | |
| self._log(f"❌ Fallback Meta-Router failed: {e}") | |
| self._log("❌ No Meta-Router available - using rule-based fallback") | |
| return None | |
| def _print_config(self): | |
| """Print detector configuration""" | |
| print(f"\n{'='*60}") | |
| print(f"SpectralDetector {self.VERSION}") | |
| print(f"Build: {self.BUILD_DATE}") | |
| print(f"{'='*60}") | |
| print(f"\nModels Loaded:") | |
| print(f" HEAD A: {'OK' if self.head_a else 'MISSING'}") | |
| print(f" HEAD D: {'OK' if self.head_d else 'MISSING'}") | |
| print(f" Meta-Router: {'OK' if self.meta_router else 'MISSING (using rule-based)'}") | |
| print(f"\nThresholds:") | |
| print(f" tau_real_high: {self.threshold_real_high}") | |
| print(f" tau_real_low: {self.threshold_real_low}") | |
| print(f" P(AI) thresh: {self.threshold_ai}") | |
| print(f"{'='*60}\n") | |
| def extract_features(self, image_path: str) -> Optional[Dict]: | |
| """ | |
| Extract spectral features from image. | |
| Args: | |
| image_path: Path to image file | |
| Returns: | |
| Dictionary with extracted features, or None if failed | |
| """ | |
| if not os.path.exists(image_path): | |
| self._log(f"❌ Image not found: {image_path}") | |
| return None | |
| try: | |
| # Load image | |
| image = cv2.imread(image_path) | |
| if image is None: | |
| self._log(f"❌ Could not load image: {image_path}") | |
| return None | |
| self._log(f"📸 Image loaded: {image.shape} - {Path(image_path).name}") | |
| # Extract features | |
| start_time = time.time() | |
| features = self.feature_extractor.extract_features(image) | |
| extraction_time = time.time() - start_time | |
| if features is None: | |
| self._log("❌ Feature extraction failed") | |
| return None | |
| self._log(f"✅ Features extracted: {len(features)} features in {extraction_time:.2f}s") | |
| return features | |
| except Exception as e: | |
| self._log(f"❌ Feature extraction error: {e}") | |
| return None | |
| def predict_head_a(self, features: Dict) -> Tuple[float, str]: | |
| """ | |
| Predict REAL vs AI using HEAD A. | |
| Args: | |
| features: Extracted features dict | |
| Returns: | |
| (p_real, status_message) | |
| """ | |
| if self.head_a is None: | |
| self._log(" HEAD A not available - using default p_real=0.5") | |
| return 0.5, "HEAD A not available" | |
| try: | |
| # Get model, scaler, and feature names | |
| model = self.head_a['model'] | |
| scaler = self.head_a.get('scaler', None) | |
| feature_names = self.head_a.get('feature_names', None) | |
| if feature_names is None: | |
| self._log(" No feature names - using all features") | |
| # Fallback: use all features | |
| feature_values = [] | |
| for key, val in features.items(): | |
| if isinstance(val, (dict, list)): | |
| val = next(iter(val.values())) if isinstance(val, dict) else (val[0] if val else 0.0) | |
| feature_values.append(float(val)) | |
| else: | |
| # Align features with expected feature names | |
| feature_values = [] | |
| for fname in feature_names: | |
| if fname in features: | |
| val = features[fname] | |
| if isinstance(val, (dict, list)): | |
| val = next(iter(val.values())) if isinstance(val, dict) else (val[0] if val else 0.0) | |
| feature_values.append(float(val)) | |
| else: | |
| feature_values.append(0.0) # Missing feature | |
| feature_array = np.array(feature_values).reshape(1, -1) | |
| # Apply scaler if available | |
| if scaler: | |
| feature_array = scaler.transform(feature_array) | |
| # Predict (class 1 = REAL, class 0 = AI) | |
| p_real = model.predict_proba(feature_array)[0][1] | |
| return float(p_real), "OK" | |
| except Exception as e: | |
| self._log(f" HEAD A prediction error: {e}") | |
| import traceback | |
| self._log(traceback.format_exc()) | |
| return 0.5, f"Error: {e}" | |
| def predict_head_d(self, features: Dict) -> Tuple[float, str]: | |
| """ | |
| Predict GPT-IMAGE-1 score using HEAD D. | |
| Args: | |
| features: Extracted features dict | |
| Returns: | |
| (head_d_score, status_message) | |
| """ | |
| if self.head_d is None: | |
| self._log(" HEAD D not available - using default score=0.0") | |
| return 0.0, "HEAD D not available" | |
| try: | |
| # Get model, scaler, and feature names | |
| model = self.head_d['model'] | |
| scaler = self.head_d.get('scaler', None) | |
| feature_names = self.head_d.get('feature_names', None) | |
| if feature_names is None: | |
| return 0.0, "No feature names available" | |
| # Align features with expected feature names | |
| feature_values = [] | |
| for fname in feature_names: | |
| if fname in features: | |
| val = features[fname] | |
| if isinstance(val, (dict, list)): | |
| val = next(iter(val.values())) if isinstance(val, dict) else (val[0] if val else 0.0) | |
| feature_values.append(float(val)) | |
| else: | |
| feature_values.append(0.0) | |
| feature_array = np.array(feature_values).reshape(1, -1) | |
| # Apply scaler if available | |
| if scaler: | |
| feature_array = scaler.transform(feature_array) | |
| # Predict (class 1 = GPT-IMAGE-1, class 0 = Other AI) | |
| head_d_score = model.predict_proba(feature_array)[0][1] | |
| return float(head_d_score), "OK" | |
| except Exception as e: | |
| self._log(f" HEAD D prediction error: {e}") | |
| return 0.0, f"Error: {e}" | |
| def soft_veto_decision(self, p_real: float, head_d_score: float) -> Tuple[str, float, str]: | |
| """ | |
| Make final decision using soft-veto meta-router. | |
| Args: | |
| p_real: HEAD A probability of REAL | |
| head_d_score: HEAD D GPT-IMAGE-1 score | |
| Returns: | |
| (decision, p_ai_final, zone) | |
| decision: 'REAL' or 'AI' | |
| p_ai_final: Final P(AI) after soft-veto | |
| zone: 'SAFE_REAL', 'SAFE_AI', or 'GRAY' | |
| """ | |
| # Compute compression awareness (simplified - assume JPEG compressed) | |
| f_compress = 1.0 # Default: assume compressed | |
| # Compute margin (distance from threshold) | |
| w_margin = abs(p_real - self.threshold_real_high) | |
| # Build meta-features | |
| meta_features = np.array([ | |
| [ | |
| -np.log(p_real + 1e-10), # neg_logit_real | |
| np.log(1 - p_real + 1e-10), # logit_ai | |
| f_compress, # compression awareness | |
| w_margin # margin from threshold | |
| ] | |
| ]) | |
| if self.meta_router: | |
| try: | |
| # Use meta-router | |
| if isinstance(self.meta_router, dict): | |
| model = self.meta_router.get('model', None) | |
| if model: | |
| p_ai_final = model.predict_proba(meta_features)[0][1] | |
| else: | |
| p_ai_final = 1 - p_real # Fallback | |
| else: | |
| p_ai_final = self.meta_router.predict_proba(meta_features)[0][1] | |
| except Exception as e: | |
| self._log(f"⚠️ Meta-router error: {e}, using fallback") | |
| p_ai_final = 1 - p_real | |
| else: | |
| # Rule-based fallback (Option 4 logic) | |
| if head_d_score > 0.58 and abs(p_real - self.threshold_real_high) <= 0.05: | |
| # Margin-gate veto activated | |
| p_ai_final = 1.0 # Force AI | |
| else: | |
| p_ai_final = 1 - p_real | |
| # Decision | |
| if p_ai_final >= self.threshold_ai: | |
| decision = "AI" | |
| else: | |
| decision = "REAL" | |
| # Zone classification | |
| if p_real >= self.threshold_real_high: | |
| zone = "SAFE_REAL" | |
| elif p_real <= self.threshold_real_low: | |
| zone = "SAFE_AI" | |
| else: | |
| zone = "GRAY" | |
| return decision, float(p_ai_final), zone | |
| def classify(self, image_path: str, save_features: Optional[str] = None) -> Dict: | |
| """ | |
| Complete classification pipeline. | |
| Args: | |
| image_path: Path to image file | |
| save_features: Optional path to save extracted features JSON | |
| Returns: | |
| Dictionary with classification results | |
| """ | |
| start_time = time.time() | |
| # Step 1: Extract features | |
| self._log(f"\n{'='*60}") | |
| self._log(f"CLASSIFYING: {Path(image_path).name}") | |
| self._log(f"{'='*60}") | |
| features = self.extract_features(image_path) | |
| if features is None: | |
| return { | |
| 'error': 'Feature extraction failed', | |
| 'image_path': image_path | |
| } | |
| # Save features if requested | |
| if save_features: | |
| with open(save_features, 'w') as f: | |
| # Convert numpy/complex types to JSON-serializable | |
| features_clean = {} | |
| for k, v in features.items(): | |
| if isinstance(v, (np.ndarray, list)): | |
| features_clean[k] = [float(x) if isinstance(x, (np.floating, np.integer)) else x for x in v] | |
| elif isinstance(v, dict): | |
| features_clean[k] = {kk: float(vv) if isinstance(vv, (np.floating, np.integer)) else vv for kk, vv in v.items()} | |
| elif isinstance(v, (np.floating, np.integer)): | |
| features_clean[k] = float(v) | |
| else: | |
| features_clean[k] = v | |
| json.dump(features_clean, f, indent=2) | |
| self._log(f"💾 Features saved: {save_features}") | |
| # Step 2: HEAD A prediction | |
| self._log("\n[HEAD A] REAL vs AI Classification...") | |
| p_real, head_a_status = self.predict_head_a(features) | |
| self._log(f" P(REAL) = {p_real:.4f} ({head_a_status})") | |
| # Step 3: HEAD D prediction | |
| self._log("\n[HEAD D] GPT-IMAGE-1 Detection...") | |
| head_d_score, head_d_status = self.predict_head_d(features) | |
| self._log(f" HEAD D Score = {head_d_score:.4f} ({head_d_status})") | |
| # Step 4: Meta-Router decision | |
| self._log("\n[META-ROUTER V3.5] Final Decision...") | |
| decision, p_ai_final, zone = self.soft_veto_decision(p_real, head_d_score) | |
| total_time = time.time() - start_time | |
| # Build result | |
| result = { | |
| 'image_path': image_path, | |
| 'decision': decision, | |
| 'p_real': round(p_real, 4), | |
| 'p_ai_final': round(p_ai_final, 4), | |
| 'head_d_score': round(head_d_score, 4), | |
| 'zone': zone, | |
| 'processing_time': round(total_time, 3), | |
| 'head_a_status': head_a_status, | |
| 'head_d_status': head_d_status, | |
| 'version': self.VERSION | |
| } | |
| # Generator identification (improved heuristic) | |
| if decision == "AI": | |
| # GPT-IMAGE-1 signature detection (primary) | |
| if head_d_score > 0.5: # Lowered from 0.7 for better detection | |
| result['likely_generator'] = "GPT-IMAGE-1" | |
| # High confidence AI with typical ChatGPT patterns | |
| elif p_ai_final > 0.85: # Lowered from 0.95 | |
| result['likely_generator'] = "ChatGPT/DALL-E-3" | |
| # Medium-high AI probability - likely SDXL/Gemini | |
| elif p_ai_final > 0.65: | |
| result['likely_generator'] = "SDXL/Gemini/MidJourney" | |
| else: | |
| result['likely_generator'] = "AI (Unknown)" | |
| else: | |
| result['likely_generator'] = "REAL" | |
| self._log(f"\n{'='*60}") | |
| self._log(f"FINAL DECISION: {decision} ({zone})") | |
| if decision == "AI": | |
| self._log(f"Likely Generator: {result['likely_generator']}") | |
| self._log(f"P(AI) Final: {p_ai_final:.4f}") | |
| self._log(f"Processing Time: {total_time:.2f}s") | |
| self._log(f"{'='*60}\n") | |
| return result | |
| def main(): | |
| """CLI interface""" | |
| parser = argparse.ArgumentParser( | |
| description="SpectralDetector V3.5 - REAL vs AI Image Classification", | |
| formatter_class=argparse.RawDescriptionHelpFormatter, | |
| epilog=""" | |
| Examples: | |
| python detect_image.py photo.jpg | |
| python detect_image.py photo.jpg --verbose | |
| python detect_image.py photo.jpg --save-features features.json | |
| python detect_image.py photo.jpg --save-result result.json | |
| """ | |
| ) | |
| parser.add_argument('image', help="Path to image file") | |
| parser.add_argument('--verbose', '-v', action='store_true', help="Enable verbose output") | |
| parser.add_argument('--save-features', help="Save extracted features to JSON file") | |
| parser.add_argument('--save-result', help="Save classification result to JSON file") | |
| parser.add_argument('--head-a', help="Path to HEAD A model (optional)") | |
| parser.add_argument('--head-d', help="Path to HEAD D model (optional)") | |
| parser.add_argument('--meta-router', help="Path to Meta-Router model (optional)") | |
| args = parser.parse_args() | |
| # Initialize detector | |
| detector = SpectralDetectorV35( | |
| head_a_path=args.head_a, | |
| head_d_path=args.head_d, | |
| meta_router_path=args.meta_router, | |
| verbose=args.verbose | |
| ) | |
| # Classify image | |
| result = detector.classify( | |
| args.image, | |
| save_features=args.save_features | |
| ) | |
| # Check for errors | |
| if 'error' in result: | |
| print(f"\n❌ ERROR: {result['error']}") | |
| return 1 | |
| # Print result | |
| if not args.verbose: | |
| print(f"\n{'='*60}") | |
| print(f"Image: {Path(args.image).name}") | |
| print(f"{'='*60}") | |
| print(f"Decision: {result['decision']}") | |
| print(f"Likely Generator: {result['likely_generator']}") | |
| print(f"P(AI) Final: {result['p_ai_final']:.4f}") | |
| print(f"P(REAL): {result['p_real']:.4f}") | |
| print(f"HEAD D Score: {result['head_d_score']:.4f}") | |
| print(f"Zone: {result['zone']}") | |
| print(f"Processing Time: {result['processing_time']:.2f}s") | |
| print(f"{'='*60}\n") | |
| # Save result if requested | |
| if args.save_result: | |
| with open(args.save_result, 'w') as f: | |
| json.dump(result, f, indent=2) | |
| print(f"💾 Result saved: {args.save_result}\n") | |
| return 0 | |
| class SpectralDetectorV36(SpectralDetectorV35): | |
| """ | |
| Enhanced detector with multi-generator identification. | |
| Architecture V3.6: | |
| - HEAD A: REAL vs AI binary classification (inherited from V3.5) | |
| - HEAD B: SDXL vs NON-SDXL detection (NEW) | |
| - HEAD C: ChatGPT vs Gemini classification (NEW) | |
| - HEAD D: GPT-IMAGE-1 veto (inherited from V3.5) | |
| - Meta-Router V3.6: 4-HEAD hierarchical integration | |
| Improvements over V3.5: | |
| - Accurate per-generator identification (SDXL, ChatGPT, Gemini, GPT-IMAGE-1) | |
| - Reduced "AI (Unknown)" rate from 85% to <20% | |
| - Maintains 92%+ REAL vs AI accuracy | |
| """ | |
| VERSION = "3.6-PRODUCTION" | |
| BUILD_DATE = "2025-11-06" | |
| # Default model paths for V3.6 | |
| DEFAULT_MODELS_V36 = { | |
| 'head_a': 'outputs/hierarchical_pipeline/head_a_model_20251023_091017.pkl', | |
| 'head_b': 'outputs/heads_v36/head_b_model.pkl', | |
| 'head_c': 'outputs/heads_v36/head_c_model.pkl', | |
| 'head_d': 'outputs/hierarchical_pipeline/head_d_model_20251023_091017.pkl', | |
| 'meta_router': 'outputs/meta_router_v36/meta_router_v36.pkl', | |
| } | |
| def __init__( | |
| self, | |
| head_a_path: Optional[str] = None, | |
| head_b_path: Optional[str] = None, | |
| head_c_path: Optional[str] = None, | |
| head_d_path: Optional[str] = None, | |
| meta_router_path: Optional[str] = None, | |
| verbose: bool = False | |
| ): | |
| """ | |
| Initialize V3.6 detector. | |
| Args: | |
| head_a_path: Path to HEAD A model (.pkl) | |
| head_b_path: Path to HEAD B model (.pkl) | |
| head_c_path: Path to HEAD C model (.pkl) | |
| head_d_path: Path to HEAD D model (.pkl) | |
| meta_router_path: Path to Meta-Router V3.6 model (.pkl) | |
| verbose: Enable verbose logging | |
| """ | |
| # Initialize parent V3.5 (loads HEAD A, HEAD D, feature extractor) | |
| super().__init__( | |
| head_a_path=head_a_path or self.DEFAULT_MODELS_V36['head_a'], | |
| head_d_path=head_d_path or self.DEFAULT_MODELS_V36['head_d'], | |
| meta_router_path=None, # We'll load V3.6 meta-router separately | |
| verbose=verbose | |
| ) | |
| # Load HEAD B (SDXL detection) | |
| self.head_b = self._load_head_b(head_b_path or self.DEFAULT_MODELS_V36['head_b']) | |
| # Load HEAD C (ChatGPT vs Gemini) | |
| self.head_c = self._load_head_c(head_c_path or self.DEFAULT_MODELS_V36['head_c']) | |
| # Load Meta-Router V3.6 | |
| self.meta_router_v36 = self._load_meta_router_v36( | |
| meta_router_path or self.DEFAULT_MODELS_V36['meta_router'] | |
| ) | |
| if self.verbose: | |
| self._log(f"\n{'='*60}") | |
| self._log(f"SpectralDetector V3.6 initialized") | |
| self._log(f"{'='*60}") | |
| def _load_head_b(self, model_path: str) -> Optional[Dict]: | |
| """Load HEAD B (SDXL detection) model""" | |
| if not os.path.exists(model_path): | |
| self._log(f"⚠️ HEAD B not found: {model_path}") | |
| return None | |
| try: | |
| model = joblib.load(model_path) | |
| file_size = os.path.getsize(model_path) | |
| # Load scaler | |
| model_dir = Path(model_path).parent | |
| scaler_path = model_dir / 'head_b_scaler.pkl' | |
| scaler = None | |
| if scaler_path.exists(): | |
| scaler = joblib.load(scaler_path) | |
| # Load feature names | |
| feature_names_path = model_dir / 'head_b_feature_names.txt' | |
| feature_names = None | |
| if feature_names_path.exists(): | |
| with open(feature_names_path, 'r') as f: | |
| feature_names = [line.strip() for line in f if line.strip()] | |
| self._log(f"✅ HEAD B loaded: {model_path} ({file_size:,} bytes)") | |
| return { | |
| 'model': model, | |
| 'scaler': scaler, | |
| 'feature_names': feature_names | |
| } | |
| except Exception as e: | |
| self._log(f"❌ HEAD B loading failed: {e}") | |
| return None | |
| def _load_head_c(self, model_path: str) -> Optional[Dict]: | |
| """Load HEAD C (ChatGPT vs Gemini) model""" | |
| if not os.path.exists(model_path): | |
| self._log(f"⚠️ HEAD C not found: {model_path}") | |
| return None | |
| try: | |
| model = joblib.load(model_path) | |
| file_size = os.path.getsize(model_path) | |
| # Load scaler | |
| model_dir = Path(model_path).parent | |
| scaler_path = model_dir / 'head_c_scaler.pkl' | |
| scaler = None | |
| if scaler_path.exists(): | |
| scaler = joblib.load(scaler_path) | |
| # Load feature names | |
| feature_names_path = model_dir / 'head_c_feature_names.txt' | |
| feature_names = None | |
| if feature_names_path.exists(): | |
| with open(feature_names_path, 'r') as f: | |
| feature_names = [line.strip() for line in f if line.strip()] | |
| # Load class mapping | |
| class_mapping_path = model_dir / 'head_c_class_mapping.json' | |
| class_mapping = None | |
| if class_mapping_path.exists(): | |
| with open(class_mapping_path, 'r') as f: | |
| class_mapping = json.load(f) | |
| self._log(f"✅ HEAD C loaded: {model_path} ({file_size:,} bytes)") | |
| if class_mapping: | |
| self._log(f" Class mapping: {class_mapping}") | |
| return { | |
| 'model': model, | |
| 'scaler': scaler, | |
| 'feature_names': feature_names, | |
| 'class_mapping': class_mapping | |
| } | |
| except Exception as e: | |
| self._log(f"❌ HEAD C loading failed: {e}") | |
| return None | |
| def _load_meta_router_v36(self, model_path: str) -> Optional[object]: | |
| """Load Meta-Router V3.6""" | |
| if not os.path.exists(model_path): | |
| self._log(f"⚠️ Meta-Router V3.6 not found: {model_path}") | |
| return None | |
| try: | |
| router = joblib.load(model_path) | |
| file_size = os.path.getsize(model_path) | |
| self._log(f"✅ Meta-Router V3.6 loaded: {model_path} ({file_size:,} bytes)") | |
| return router | |
| except Exception as e: | |
| self._log(f"❌ Meta-Router V3.6 loading failed: {e}") | |
| return None | |
| def classify(self, image_path: str) -> Dict: | |
| """ | |
| Classify image with V3.6 multi-HEAD architecture. | |
| Returns: | |
| -------- | |
| dict with keys: | |
| - final_label: str (REAL, SDXL, ChatGPT, Gemini, GPT-IMAGE-1, AI (Unknown)) | |
| - confidence: float | |
| - confidence_zone: str (HIGH, MEDIUM, LOW) | |
| - p_real: float | |
| - p_ai: float | |
| - p_sdxl: float (NEW in V3.6) | |
| - p_chatgpt: float (NEW in V3.6) | |
| - p_gemini: float (NEW in V3.6) | |
| - p_gpt_image_1: float | |
| - processing_time_ms: float | |
| - version: str | |
| """ | |
| start_time = time.time() | |
| # Extract features | |
| features = self.extract_features(image_path) | |
| if features is None or 'error' in features: | |
| return { | |
| 'error': 'Feature extraction failed', | |
| 'details': features.get('error') if features else 'Unknown error', | |
| 'version': self.VERSION | |
| } | |
| # Prepare feature vector (exclude metadata) | |
| exclude_cols = ['image_path', 'filepath', 'source', 'label', 'model_version'] | |
| feature_names = [k for k in features.keys() if k not in exclude_cols] | |
| X = np.array([features[k] for k in feature_names]).reshape(1, -1) | |
| # HEAD A: REAL vs AI | |
| if self.head_a is None: | |
| return {'error': 'HEAD A not loaded', 'version': self.VERSION} | |
| if self.head_a['scaler']: | |
| X_a = self.head_a['scaler'].transform(X) | |
| else: | |
| X_a = X | |
| p_ai = self.head_a['model'].predict_proba(X_a)[0, 1] | |
| p_real = 1 - p_ai | |
| # HEAD D: GPT-IMAGE-1 detection | |
| p_gpt_image_1 = 0.0 | |
| if self.head_d is not None: | |
| if self.head_d['scaler']: | |
| X_d = self.head_d['scaler'].transform(X) | |
| else: | |
| X_d = X | |
| p_gpt_image_1 = self.head_d['model'].predict_proba(X_d)[0, 1] | |
| # HEAD B: SDXL detection | |
| p_sdxl = 0.0 | |
| if self.head_b is not None: | |
| if self.head_b['scaler']: | |
| X_b = self.head_b['scaler'].transform(X) | |
| else: | |
| X_b = X | |
| p_sdxl = self.head_b['model'].predict_proba(X_b)[0, 1] | |
| # HEAD C: ChatGPT vs Gemini | |
| p_chatgpt = 0.0 | |
| p_gemini = 0.0 | |
| if self.head_c is not None: | |
| if self.head_c['scaler']: | |
| X_c = self.head_c['scaler'].transform(X) | |
| else: | |
| X_c = X | |
| proba = self.head_c['model'].predict_proba(X_c)[0] | |
| # Map to class names (class_mapping: {'chatgpt': 0, 'gemini': 1}) | |
| if self.head_c['class_mapping']: | |
| chatgpt_idx = self.head_c['class_mapping'].get('chatgpt', 0) | |
| gemini_idx = self.head_c['class_mapping'].get('gemini', 1) | |
| p_chatgpt = proba[chatgpt_idx] | |
| p_gemini = proba[gemini_idx] | |
| else: | |
| # Fallback: assume index 0=chatgpt, 1=gemini | |
| p_chatgpt = proba[0] | |
| p_gemini = proba[1] | |
| # Meta-Router V3.6: Hierarchical decision | |
| if self.meta_router_v36 is not None: | |
| result = self.meta_router_v36.predict( | |
| head_a_proba=p_ai, | |
| head_b_proba=p_sdxl, | |
| head_c_proba=[p_chatgpt, p_gemini], | |
| head_d_proba=p_gpt_image_1 | |
| ) | |
| else: | |
| # Fallback to simple heuristic if meta-router not loaded | |
| result = self._fallback_decision(p_real, p_ai, p_sdxl, p_chatgpt, p_gemini, p_gpt_image_1) | |
| # Add timing and version | |
| processing_time = (time.time() - start_time) * 1000 | |
| result['processing_time_ms'] = processing_time | |
| result['version'] = self.VERSION | |
| return result | |
| def _fallback_decision(self, p_real, p_ai, p_sdxl, p_chatgpt, p_gemini, p_gpt_image_1): | |
| """Fallback decision logic if meta-router not loaded""" | |
| # STEP 1: REAL vs AI | |
| if p_real > 0.5: | |
| confidence_zone = 'HIGH' if p_real >= 0.7 else 'MEDIUM' if p_real >= 0.3 else 'LOW' | |
| return { | |
| 'final_label': 'REAL', | |
| 'confidence': p_real, | |
| 'confidence_zone': confidence_zone, | |
| 'p_real': p_real, | |
| 'p_ai': p_ai, | |
| 'p_sdxl': p_sdxl, | |
| 'p_chatgpt': p_chatgpt, | |
| 'p_gemini': p_gemini, | |
| 'p_gpt_image_1': p_gpt_image_1 | |
| } | |
| # STEP 2: AI detected - cascade | |
| # 2a. GPT-IMAGE-1 veto | |
| if p_gpt_image_1 >= 0.7: | |
| confidence_zone = 'HIGH' if p_gpt_image_1 >= 0.85 else 'MEDIUM' | |
| return { | |
| 'final_label': 'GPT-IMAGE-1', | |
| 'confidence': p_gpt_image_1, | |
| 'confidence_zone': confidence_zone, | |
| 'p_real': p_real, | |
| 'p_ai': p_ai, | |
| 'p_sdxl': p_sdxl, | |
| 'p_chatgpt': p_chatgpt, | |
| 'p_gemini': p_gemini, | |
| 'p_gpt_image_1': p_gpt_image_1 | |
| } | |
| # 2b. SDXL detection | |
| if p_sdxl >= 0.8: | |
| confidence_zone = 'HIGH' if p_sdxl >= 0.9 else 'MEDIUM' | |
| return { | |
| 'final_label': 'SDXL', | |
| 'confidence': p_sdxl, | |
| 'confidence_zone': confidence_zone, | |
| 'p_real': p_real, | |
| 'p_ai': p_ai, | |
| 'p_sdxl': p_sdxl, | |
| 'p_chatgpt': p_chatgpt, | |
| 'p_gemini': p_gemini, | |
| 'p_gpt_image_1': p_gpt_image_1 | |
| } | |
| # 2c. ChatGPT vs Gemini | |
| if p_chatgpt > p_gemini: | |
| if p_chatgpt >= 0.6: | |
| confidence_zone = 'HIGH' if p_chatgpt >= 0.8 else 'MEDIUM' | |
| return { | |
| 'final_label': 'ChatGPT', | |
| 'confidence': p_chatgpt, | |
| 'confidence_zone': confidence_zone, | |
| 'p_real': p_real, | |
| 'p_ai': p_ai, | |
| 'p_sdxl': p_sdxl, | |
| 'p_chatgpt': p_chatgpt, | |
| 'p_gemini': p_gemini, | |
| 'p_gpt_image_1': p_gpt_image_1 | |
| } | |
| else: | |
| if p_gemini >= 0.6: | |
| confidence_zone = 'HIGH' if p_gemini >= 0.8 else 'MEDIUM' | |
| return { | |
| 'final_label': 'Gemini', | |
| 'confidence': p_gemini, | |
| 'confidence_zone': confidence_zone, | |
| 'p_real': p_real, | |
| 'p_ai': p_ai, | |
| 'p_sdxl': p_sdxl, | |
| 'p_chatgpt': p_chatgpt, | |
| 'p_gemini': p_gemini, | |
| 'p_gpt_image_1': p_gpt_image_1 | |
| } | |
| # 2d. Fallback: AI (Unknown) | |
| max_ai_confidence = max(p_ai, p_sdxl, p_chatgpt, p_gemini, p_gpt_image_1) | |
| return { | |
| 'final_label': 'AI (Unknown)', | |
| 'confidence': max_ai_confidence, | |
| 'confidence_zone': 'LOW', | |
| 'p_real': p_real, | |
| 'p_ai': p_ai, | |
| 'p_sdxl': p_sdxl, | |
| 'p_chatgpt': p_chatgpt, | |
| 'p_gemini': p_gemini, | |
| 'p_gpt_image_1': p_gpt_image_1 | |
| } | |
| class SpectralDetectorV36Cascaded: | |
| """ | |
| V3.6 Cascaded Router with 4-HEAD system (A, B, C, D). | |
| Architecture: | |
| - HEAD A: REAL vs AI (gate) | |
| - HEAD B: SDXL detection | |
| - HEAD C: ChatGPT vs Gemini | |
| - HEAD D: GPT-IMAGE-1 detection | |
| - Meta-Router V3.6: Cascaded decision with fallback | |
| Feature extraction: | |
| - HEAD A/B/C: 67 features | |
| - HEAD D: 90 features (includes extra global/patch features) | |
| """ | |
| VERSION = "3.6-CASCADED" | |
| BUILD_DATE = "2025-11-07" | |
| DEFAULT_MODELS = { | |
| 'head_a': 'outputs/heads_v36_67features/head_a_model.pkl', | |
| 'head_b': 'outputs/heads_v36_67features/head_b_model.pkl', | |
| 'head_c': 'outputs/heads_v36_67features/head_c_model.pkl', | |
| 'head_d': 'outputs/heads_v36_67features/head_d_model.pkl', | |
| 'calibrators': 'outputs/heads_v36_67features/' | |
| } | |
| def __init__( | |
| self, | |
| models_dir: Optional[str] = None, | |
| verbose: bool = False | |
| ): | |
| """Initialize V3.6 detector with all 4 HEADs + calibrators.""" | |
| self.verbose = verbose | |
| self.models_dir = models_dir or self.DEFAULT_MODELS['calibrators'] | |
| # Feature extractor (same config as training) | |
| self.feature_extractor = FFTFeatureExtractor( | |
| target_size=1024, | |
| grayscale=True, | |
| saturation_features=True, | |
| chroma_features=False, | |
| exclude_jpeg_freqs=True | |
| ) | |
| # Load models and feature names | |
| self._load_models() | |
| # Initialize meta-router | |
| from meta_router_v36_cascaded import create_router_option3 | |
| self.router = create_router_option3() | |
| if self.verbose: | |
| self._log(f"✅ SpectralDetector V{self.VERSION} initialized") | |
| def _log(self, message: str): | |
| """Print if verbose""" | |
| if self.verbose: | |
| try: | |
| print(message) | |
| except UnicodeEncodeError: | |
| print(message.encode('ascii', 'ignore').decode('ascii')) | |
| def _load_models(self): | |
| """Load all 4 HEADs + calibrators + feature names""" | |
| import joblib | |
| models_path = Path(self.models_dir) | |
| # Load HEAD A/B/C (67 features) | |
| self.head_a = joblib.load(models_path / 'head_a_model.pkl') | |
| self.head_b = joblib.load(models_path / 'head_b_model.pkl') | |
| self.head_c = joblib.load(models_path / 'head_c_model.pkl') | |
| self.head_d = joblib.load(models_path / 'head_d_model.pkl') | |
| # Load scalers | |
| self.scaler_a = joblib.load(models_path / 'head_a_scaler.pkl') | |
| self.scaler_b = joblib.load(models_path / 'head_b_scaler.pkl') | |
| self.scaler_c = joblib.load(models_path / 'head_c_scaler.pkl') | |
| self.scaler_d = joblib.load(models_path / 'head_d_scaler.pkl') | |
| # Load feature names | |
| with open(models_path / 'head_a_features.txt', 'r') as f: | |
| self.features_67 = [line.strip() for line in f] | |
| with open(models_path / 'head_d_features.txt', 'r') as f: | |
| self.features_90 = [line.strip() for line in f] | |
| # Load calibrators (temperature scalers) | |
| try: | |
| self.calibrator_a = joblib.load(models_path / 'head_a_temperature_scaler.pkl') | |
| self.calibrator_b = joblib.load(models_path / 'head_b_temperature_scaler.pkl') | |
| self.calibrator_c = joblib.load(models_path / 'head_c_temperature_scaler.pkl') | |
| self._log(" Loaded temperature calibrators") | |
| except FileNotFoundError: | |
| self._log(" ⚠️ Temperature calibrators not found (using uncalibrated)") | |
| self.calibrator_a = self.calibrator_b = self.calibrator_c = None | |
| self._log(f" Loaded 4 HEADs: {len(self.features_67)} features (A/B/C), {len(self.features_90)} features (D)") | |
| def classify_image(self, image_path: str) -> Dict: | |
| """ | |
| Classify a single image. | |
| Args: | |
| image_path: Path to image file | |
| Returns: | |
| Dict with decision, confidence, route, probabilities | |
| """ | |
| # Load image | |
| img = cv2.imread(str(image_path)) | |
| if img is None: | |
| raise ValueError(f"Cannot load image: {image_path}") | |
| # Extract features (full set for 90-feature extraction) | |
| features_dict = self.feature_extractor.extract_features(img) | |
| # Convert to DataFrame | |
| features_df = pd.DataFrame([features_dict]) | |
| # Extract 67 features for HEAD A/B/C | |
| X_67 = features_df[self.features_67].values | |
| X_67_scaled = self.scaler_a.transform(X_67) # Scaler A (same for B/C) | |
| # Extract 90 features for HEAD D | |
| X_90 = features_df[self.features_90].values | |
| X_90_scaled = self.scaler_d.transform(X_90) | |
| # HEAD A: REAL vs AI | |
| proba_a = self.head_a.predict_proba(X_67_scaled)[0] | |
| p_real_raw = proba_a[0] # Class 0 = REAL | |
| p_ai_raw = proba_a[1] # Class 1 = AI | |
| # Calibrate HEAD A | |
| if self.calibrator_a: | |
| logits_a = np.log(proba_a / (1 - proba_a + 1e-10)) | |
| proba_a_cal = self.calibrator_a.transform(logits_a.reshape(1, -1))[0] | |
| p_real = proba_a_cal[0] | |
| p_ai = proba_a_cal[1] | |
| else: | |
| p_real = p_real_raw | |
| p_ai = p_ai_raw | |
| # HEAD B: SDXL detection | |
| proba_b = self.head_b.predict_proba(X_67_scaled)[0] | |
| p_sdxl_raw = proba_b[1] # Assuming class 1 = SDXL | |
| if self.calibrator_b: | |
| logits_b = np.log(proba_b / (1 - proba_b + 1e-10)) | |
| proba_b_cal = self.calibrator_b.transform(logits_b.reshape(1, -1))[0] | |
| p_sdxl = proba_b_cal[1] | |
| else: | |
| p_sdxl = p_sdxl_raw | |
| # HEAD C: ChatGPT vs Gemini | |
| proba_c = self.head_c.predict_proba(X_67_scaled)[0] | |
| p_chatgpt_raw = proba_c[0] # Assuming class 0 = ChatGPT | |
| p_gemini_raw = proba_c[1] | |
| if self.calibrator_c: | |
| logits_c = np.log(proba_c / (1 - proba_c + 1e-10)) | |
| proba_c_cal = self.calibrator_c.transform(logits_c.reshape(1, -1))[0] | |
| p_chatgpt = proba_c_cal[0] | |
| p_gemini = proba_c_cal[1] | |
| else: | |
| p_chatgpt = p_chatgpt_raw | |
| p_gemini = p_gemini_raw | |
| # HEAD D: GPT-IMAGE-1 detection | |
| proba_d = self.head_d.predict_proba(X_90_scaled)[0] | |
| p_gpt_image_1 = proba_d[1] # Assuming class 1 = GPT-IMAGE-1 | |
| # Meta-Router V3.6 decision | |
| decision = self.router.predict( | |
| p_real=p_real, | |
| p_ai=p_ai, | |
| p_gpt_image_1=p_gpt_image_1, | |
| p_sdxl=p_sdxl, | |
| p_chatgpt=p_chatgpt, | |
| p_gemini=p_gemini | |
| ) | |
| return { | |
| 'final_label': decision.final_label, | |
| 'confidence': decision.confidence, | |
| 'route_taken': decision.route_taken, | |
| 'exit_head': decision.exit_head, | |
| 'threshold_hit': decision.threshold_hit, | |
| 'probabilities': decision.probabilities, | |
| 'scores': decision.scores | |
| } | |
| if __name__ == '__main__': | |
| sys.exit(main()) | |