Spaces:
Paused
Paused
Download api/model_server.py from MohsinElis/patch-mfft-api: direct link, hf CLI and curl.
- Browser
- Download file 25 kB
-
https://huggingface.co/spaces/MohsinElis/patch-mfft-api/resolve/main/api/model_server.py
- Command line
-
hf download hf://spaces/MohsinElis/patch-mfft-api/api/model_server.py
-
curl -L -o model_server.py https://huggingface.co/spaces/MohsinElis/patch-mfft-api/resolve/main/api/model_server.py
25 kB
| """ | |
| Model Server for Patch-MFFT Tampering Forensics. | |
| Handles inference, aspect-ratio preserving preprocessing, | |
| 3-class prediction, and pixel-level tampering localization masks. | |
| """ | |
| import base64 | |
| import io | |
| import time | |
| from pathlib import Path | |
| from typing import Any, Dict, Optional | |
| import numpy as np | |
| from PIL import Image | |
| import torch | |
| import torch.nn.functional as F | |
| import sys | |
| sys.path.insert(0, str(Path(__file__).parent.parent / "model" / "src")) | |
| try: | |
| from model.src.model import PatchMFFT, build_patch_mfft | |
| except ImportError: | |
| from model import PatchMFFT, build_patch_mfft | |
| TURBO_LUT = np.array([ | |
| [48, 18, 59], [49, 21, 66], [50, 24, 74], [52, 27, 81], [53, 30, 88], [54, 33, 95], [55, 35, 101], [56, 38, 108], | |
| [57, 41, 114], [58, 44, 121], [59, 47, 127], [60, 50, 133], [60, 53, 139], [61, 55, 145], [62, 58, 150], [63, 61, 156], | |
| [64, 64, 161], [64, 67, 166], [65, 69, 171], [65, 72, 176], [66, 75, 181], [67, 78, 186], [67, 80, 190], [67, 83, 194], | |
| [68, 86, 199], [68, 88, 203], [69, 91, 206], [69, 94, 210], [69, 96, 214], [69, 99, 217], [70, 102, 221], [70, 104, 224], | |
| [70, 107, 227], [70, 109, 230], [70, 112, 232], [70, 115, 235], [70, 117, 237], [70, 120, 240], [70, 122, 242], [70, 125, 244], | |
| [70, 127, 246], [70, 130, 248], [69, 132, 249], [69, 135, 251], [69, 137, 252], [68, 140, 253], [67, 142, 253], [66, 145, 254], | |
| [65, 147, 254], [64, 150, 254], [63, 152, 254], [62, 155, 254], [60, 157, 253], [59, 160, 252], [57, 162, 252], [56, 165, 251], | |
| [54, 168, 249], [52, 170, 248], [51, 172, 246], [49, 175, 245], [47, 177, 243], [45, 180, 241], [43, 182, 239], [42, 185, 237], | |
| [40, 187, 235], [38, 189, 233], [37, 192, 230], [35, 194, 228], [33, 196, 225], [32, 198, 223], [30, 201, 220], [29, 203, 218], | |
| [28, 205, 215], [27, 207, 212], [26, 209, 210], [25, 211, 207], [24, 213, 204], [24, 215, 202], [23, 217, 199], [23, 218, 196], | |
| [23, 220, 194], [23, 222, 191], [24, 224, 189], [24, 225, 186], [25, 227, 184], [26, 228, 182], [27, 229, 180], [29, 231, 177], | |
| [30, 232, 175], [32, 233, 172], [34, 235, 169], [36, 236, 166], [39, 237, 163], [41, 238, 160], [44, 239, 157], [47, 240, 154], | |
| [50, 241, 151], [53, 243, 148], [56, 244, 145], [59, 244, 141], [63, 245, 138], [66, 246, 135], [70, 247, 131], [74, 248, 128], | |
| [77, 249, 124], [81, 249, 121], [85, 250, 118], [89, 251, 114], [93, 251, 111], [97, 252, 108], [101, 252, 104], [105, 253, 101], | |
| [109, 253, 98], [113, 253, 95], [116, 254, 92], [120, 254, 89], [124, 254, 86], [128, 254, 83], [132, 254, 80], [135, 254, 77], | |
| [139, 254, 75], [142, 254, 72], [146, 254, 70], [149, 254, 68], [152, 254, 66], [155, 253, 64], [158, 253, 62], [161, 252, 61], | |
| [164, 252, 59], [166, 251, 58], [169, 251, 57], [172, 250, 55], [174, 249, 55], [177, 248, 54], [179, 248, 53], [182, 247, 53], | |
| [185, 245, 52], [187, 244, 52], [190, 243, 52], [192, 242, 51], [195, 241, 51], [197, 239, 51], [200, 238, 51], [202, 237, 51], | |
| [205, 235, 52], [207, 234, 52], [209, 232, 52], [212, 231, 53], [214, 229, 53], [216, 227, 53], [218, 226, 54], [221, 224, 54], | |
| [223, 222, 54], [225, 220, 55], [227, 218, 55], [229, 216, 56], [231, 215, 56], [232, 213, 56], [234, 211, 57], [236, 209, 57], | |
| [237, 207, 57], [239, 205, 57], [240, 203, 58], [242, 200, 58], [243, 198, 58], [244, 196, 58], [246, 194, 58], [247, 192, 57], | |
| [248, 190, 57], [249, 188, 57], [249, 186, 56], [250, 183, 55], [251, 181, 55], [251, 179, 54], [252, 176, 53], [252, 174, 52], | |
| [253, 171, 51], [253, 169, 50], [253, 166, 49], [253, 163, 48], [254, 161, 47], [254, 158, 46], [254, 155, 45], [254, 152, 44], | |
| [253, 149, 43], [253, 146, 41], [253, 143, 40], [253, 140, 39], [252, 137, 38], [252, 134, 36], [251, 131, 35], [251, 128, 34], | |
| [250, 125, 32], [250, 122, 31], [249, 119, 30], [248, 116, 28], [247, 113, 27], [247, 110, 26], [246, 107, 24], [245, 104, 23], | |
| [244, 101, 22], [243, 99, 21], [242, 96, 20], [241, 93, 19], [239, 90, 17], [238, 88, 16], [237, 85, 15], [236, 82, 14], | |
| [234, 80, 13], [233, 77, 13], [232, 75, 12], [230, 73, 11], [229, 70, 10], [227, 68, 10], [226, 66, 9], [224, 64, 8], | |
| [222, 62, 8], [221, 60, 7], [219, 58, 7], [217, 56, 6], [215, 54, 6], [214, 52, 5], [212, 50, 5], [210, 48, 5], | |
| [208, 47, 4], [206, 45, 4], [203, 43, 3], [201, 41, 3], [199, 40, 3], [197, 38, 2], [195, 36, 2], [192, 35, 2], | |
| [190, 33, 2], [187, 31, 1], [185, 30, 1], [182, 28, 1], [180, 27, 1], [177, 25, 1], [174, 24, 1], [172, 22, 1], | |
| [169, 21, 1], [166, 20, 1], [163, 18, 1], [160, 17, 1], [157, 16, 1], [154, 14, 1], [151, 13, 1], [148, 12, 1], | |
| [145, 11, 1], [142, 10, 1], [139, 9, 1], [135, 8, 1], [132, 7, 1], [129, 6, 2], [125, 5, 2], [122, 4, 2] | |
| ], dtype=np.uint8) | |
| def extract_exif_metadata(image: Image.Image) -> Dict[str, Any]: | |
| """ | |
| Extracts camera hardware metadata and detects AI generation software tags | |
| embedded within EXIF, XMP, or PNG text segments. | |
| """ | |
| exif_summary = {} | |
| generative_tags = [] | |
| has_exif = False | |
| gen_keywords = [ | |
| "firefly", "stable diffusion", "midjourney", "dall-e", "dalle", | |
| "comfyui", "automatic1111", "novelai", "invokeai", "photoshop", | |
| "generative", "canva", "bing creator" | |
| ] | |
| try: | |
| from PIL import ExifTags | |
| raw_exif = image.getexif() | |
| if raw_exif: | |
| for tag_id, val in raw_exif.items(): | |
| tag_name = ExifTags.TAGS.get(tag_id, str(tag_id)) | |
| val_str = str(val).strip() | |
| if val_str and len(val_str) < 120: | |
| exif_summary[tag_name] = val_str | |
| has_exif = True | |
| for kw in gen_keywords: | |
| if kw in val_str.lower() and kw not in generative_tags: | |
| generative_tags.append(kw) | |
| except Exception: | |
| pass | |
| for k, v in image.info.items(): | |
| if isinstance(v, (str, bytes)): | |
| v_str = str(v).strip() | |
| if len(v_str) < 150: | |
| exif_summary[f"info_{k}"] = v_str | |
| for kw in gen_keywords: | |
| if kw in v_str.lower() and kw not in generative_tags: | |
| generative_tags.append(kw) | |
| # Focus on standard photographic capture audit parameters | |
| key_tags = [ | |
| "Make", "Model", "Software", "DateTimeOriginal", "LensModel", | |
| "ExposureTime", "FNumber", "ISOSpeedRatings", "FocalLength" | |
| ] | |
| filtered_exif = {k: exif_summary[k] for k in key_tags if k in exif_summary} | |
| if not filtered_exif and exif_summary: | |
| filtered_exif = {k: v for i, (k, v) in enumerate(exif_summary.items()) if i < 8} | |
| return { | |
| "has_exif": has_exif, | |
| "exif_summary": filtered_exif if filtered_exif else None, | |
| "generative_software_tag": ", ".join(generative_tags) if generative_tags else None, | |
| "is_generative_exif": len(generative_tags) > 0, | |
| } | |
| def check_photographic_domain( | |
| image: Image.Image, | |
| filename: Optional[str] = None, | |
| ) -> Dict[str, Any]: | |
| """ | |
| Inspects whether an input image conforms to optical photographic statistics | |
| or represents Out-of-Distribution (OOD) digital graphics (e.g. draw.io diagrams, | |
| software screenshots, vector line art, pure blank canvas, or stylized anime/art). | |
| """ | |
| img_rgb = image.convert("RGB") | |
| arr = np.array(img_rgb, dtype=np.float32) | |
| h, w, _ = arr.shape | |
| total_pixels = h * w | |
| std_dev = float(np.std(arr)) | |
| # Color quantization | |
| quantized = (arr // 32).astype(np.int32) | |
| hash_vals = quantized[:, :, 0] * 64 + quantized[:, :, 1] * 8 + quantized[:, :, 2] | |
| unique, counts = np.unique(hash_vals, return_counts=True) | |
| top_color_ratio = float(np.max(counts)) / total_pixels | |
| unique_bins = len(unique) | |
| # Exact background flat pixel ratio | |
| flat_pixels_exact = ( | |
| float(np.max(np.unique(arr.reshape(-1, 3), axis=0, return_counts=True)[1])) | |
| / total_pixels | |
| ) | |
| # Pure NumPy Grayscale (Rec. 601 luma) | |
| gray = 0.299 * arr[:, :, 0] + 0.587 * arr[:, :, 1] + 0.114 * arr[:, :, 2] | |
| # Color saturation in pure NumPy | |
| max_c = np.max(arr, axis=2) | |
| min_c = np.min(arr, axis=2) | |
| sat = np.where(max_c > 0, (max_c - min_c) / (max_c + 1e-6) * 255.0, 0.0) | |
| high_sat_ratio = float(np.mean(sat > 100)) | |
| is_ood = False | |
| domain_status = "photographic" | |
| warning = None | |
| # Check 1: Filename metadata indication | |
| fname_lower = (filename or "").lower() | |
| art_keywords = [ | |
| "ghibli", "chatgpt", "dall-e", "dalle", "midjourney", "anime", | |
| "cartoon", "illustration", "sketch", "draw.io", "diagram", | |
| "flowchart", "figma", "screenshot", "clipart", "render" | |
| ] | |
| found_keywords = [k for k in art_keywords if k in fname_lower] | |
| if found_keywords: | |
| is_ood = True | |
| domain_status = "stylized_digital_graphic" | |
| kw_str = ", ".join(found_keywords) | |
| warning = ( | |
| f"Non-photographic / stylized digital artwork detected ({kw_str}). " | |
| f"Forensics models are calibrated on camera optics; synthetic cartoon styling alters natural sensor noise." | |
| ) | |
| elif std_dev < 1.5: | |
| is_ood = True | |
| domain_status = "solid_canvas_ood" | |
| warning = ( | |
| "Input is a solid blank canvas with zero sensor noise variance (not an optical photograph)." | |
| ) | |
| elif flat_pixels_exact > 0.50 and unique_bins < 100: | |
| is_ood = True | |
| domain_status = "non_photographic_graphic" | |
| warning = ( | |
| f"Input appears to be a digital diagram or vector graphic (e.g., draw.io, UI screenshot) " | |
| f"with {flat_pixels_exact * 100:.1f}% flat background. Forensics models require camera optical physics." | |
| ) | |
| elif unique_bins < 25 and top_color_ratio > 0.40: | |
| is_ood = True | |
| domain_status = "non_photographic_graphic" | |
| warning = ( | |
| f"Input appears to be a digital sketch or illustration with only {unique_bins} discrete color bins. " | |
| f"Forensics models require camera optical physics." | |
| ) | |
| elif high_sat_ratio > 0.85: | |
| is_ood = True | |
| domain_status = "stylized_digital_graphic" | |
| warning = ( | |
| f"Hyper-saturated digital illustration profile detected ({high_sat_ratio * 100:.1f}% saturated pixels). " | |
| f"Non-photographic artwork." | |
| ) | |
| # 8x8 DCT JPEG Blockiness calculation (Pure NumPy, vectorized across horizontal and vertical axes) | |
| h_blocks = h // 8 | |
| w_blocks = w // 8 | |
| blockiness = 1.0 | |
| if h_blocks > 2 and w_blocks > 2: | |
| b_rows = np.arange(1, h_blocks) * 8 | |
| boundary_diff_h = np.mean(np.abs(gray[b_rows, :] - gray[b_rows - 1, :])) | |
| interior_diff_h = np.mean(np.abs(gray[b_rows - 1, :] - gray[b_rows - 2, :])) | |
| blk_h = boundary_diff_h / (interior_diff_h + 1e-6) | |
| b_cols = np.arange(1, w_blocks) * 8 | |
| boundary_diff_v = np.mean(np.abs(gray[:, b_cols] - gray[:, b_cols - 1])) | |
| interior_diff_v = np.mean(np.abs(gray[:, b_cols - 1] - gray[:, b_cols - 2])) | |
| blk_v = boundary_diff_v / (interior_diff_v + 1e-6) | |
| blockiness = float((blk_h + blk_v) / 2.0) | |
| # Social media compression indicators | |
| social_media_keywords = [ | |
| "whatsapp", "telegram", "messenger", "instagram", "discord", | |
| "facebook", "fb_", "signal", "wechat", "viber" | |
| ] | |
| has_sm_keyword = any(k in fname_lower for k in social_media_keywords) | |
| is_social_media_compressed = has_sm_keyword or (blockiness > 1.25) | |
| compression_warning = None | |
| if is_social_media_compressed: | |
| compression_warning = ( | |
| f"Social media lossy compression detected (8x8 DCT blockiness {blockiness:.2f}). " | |
| f"Messaging platforms (WhatsApp/Telegram) discard high-frequency Fourier harmonics (see Manuscript Table 7). " | |
| f"Treat 'Authentic' classifications with caution." | |
| ) | |
| full_warning = warning | |
| if compression_warning: | |
| full_warning = f"{full_warning} | {compression_warning}" if full_warning else compression_warning | |
| return { | |
| "is_non_photographic_graphic": is_ood, | |
| "domain_status": domain_status, | |
| "is_social_media_compressed": is_social_media_compressed, | |
| "compression_blockiness": round(blockiness, 3), | |
| "warning": full_warning, | |
| } | |
| class TamperingModelServer: | |
| def __init__( | |
| self, | |
| checkpoint_path: Optional[str] = None, | |
| variant: str = "large", | |
| img_size: int = 384, | |
| ): | |
| self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu") | |
| self.variant = variant | |
| self.img_size = img_size | |
| print(f"[TamperingModelServer] Initializing Patch-MFFT ({variant}) on {self.device}") | |
| self.model = build_patch_mfft(variant, img_size=img_size, num_classes=3) | |
| self.model = self.model.to(self.device) | |
| self.is_loaded = False | |
| if checkpoint_path and Path(checkpoint_path).exists(): | |
| self.load_checkpoint(checkpoint_path) | |
| else: | |
| print(f"[TamperingModelServer] Running with initialized weights (no checkpoint at {checkpoint_path})") | |
| self.model.eval() | |
| self.warmup() | |
| def load_checkpoint(self, path: str): | |
| try: | |
| state = torch.load(path, map_location=self.device, weights_only=True) | |
| except Exception: | |
| state = torch.load(path, map_location=self.device, weights_only=False) | |
| raw_state = state["model_state_dict"] if isinstance(state, dict) and "model_state_dict" in state else state | |
| self.model.load_state_dict(raw_state, strict=True) | |
| self.is_loaded = True | |
| print(f"[TamperingModelServer] Successfully loaded weights with strict=True from {path}") | |
| def warmup(self, iters: int = 2): | |
| dummy = torch.randn(1, 3, self.img_size, self.img_size).to(self.device) | |
| with torch.no_grad(): | |
| for _ in range(iters): | |
| _ = self.model(dummy, return_mask=True) | |
| print(f"[TamperingModelServer] Warmup completed ({iters} iterations).") | |
| def _preprocess(self, image: Image.Image) -> torch.Tensor: | |
| # Standardize directly to model input resolution via bilinear resampling, | |
| # perfectly matching the training dataset pipeline (Cell 14 of kaggle_patch_mfft_pipeline) | |
| img_res = image.convert("RGB").resize((self.img_size, self.img_size), Image.Resampling.BILINEAR) | |
| img_array = np.array(img_res, dtype=np.float32) / 255.0 | |
| mean = np.array([0.485, 0.456, 0.406], dtype=np.float32) | |
| std = np.array([0.229, 0.224, 0.225], dtype=np.float32) | |
| img_array = ((img_array - mean) / std).astype(np.float32) | |
| tensor = torch.from_numpy(img_array).permute(2, 0, 1).unsqueeze(0) | |
| return tensor.to(self.device) | |
| def predict( | |
| self, | |
| image: Image.Image, | |
| filename: Optional[str] = None, | |
| sha256_hash: Optional[str] = None, | |
| ) -> Dict[str, Any]: | |
| start = time.time() | |
| tensor = self._preprocess(image) | |
| logits, mask = self.model(tensor, return_mask=True) | |
| probs = F.softmax(logits, dim=-1)[0] | |
| pred_idx = torch.argmax(probs).item() | |
| classes = ["authentic", "full_ai", "spliced_tampered"] | |
| prediction_label = classes[pred_idx] | |
| class_probs = { | |
| "authentic": round(probs[0].item(), 4), | |
| "full_ai": round(probs[1].item(), 4), | |
| "spliced_tampered": round(probs[2].item(), 4), | |
| } | |
| # Raw evaluated pixel-level tampering probability from dense convolutional decoder (loc): | |
| # Directly evaluated from multi-frequency feature map, without artificial color or class overrides. | |
| mask_np = torch.sigmoid(mask)[0, 0].cpu().numpy() | |
| peak_score = round(float(mask_np.max()), 4) | |
| coverage_ratio = round(float(np.mean(mask_np > 0.50)), 4) | |
| # 1. Standalone publication-style Turbo false-color RGB heatmap (Matches Figure 2 Column d in paper) | |
| # 0.0 (Deep Navy/Indigo): Pristine Authentic | 0.50 (Lime Green): Transition | 1.0 (Crimson Red): High Tampering | |
| lut_idx = np.clip(mask_np * 255.0, 0, 255).astype(np.int32) | |
| heatmap_rgb = TURBO_LUT[lut_idx] | |
| mask_pil = Image.fromarray(heatmap_rgb, mode="RGB") | |
| buf = io.BytesIO() | |
| mask_pil.save(buf, format="PNG") | |
| mask_b64 = base64.b64encode(buf.getvalue()).decode("utf-8") | |
| # 2. Seamless RGBA Overlay: Authentic background is 100% transparent (alpha=0), | |
| # only tampering hotspots glow in vivid Turbo colors without discoloring the original photograph | |
| overlay_rgba = np.zeros((self.img_size, self.img_size, 4), dtype=np.uint8) | |
| overlay_rgba[:, :, :3] = heatmap_rgb | |
| # Threshold: authentic pixels (p < 0.12) are completely transparent; ramps smoothly up to 210 alpha for hotspots | |
| alpha_map = np.clip((mask_np - 0.12) / 0.25 * 210.0, 0, 210).astype(np.uint8) | |
| overlay_rgba[:, :, 3] = alpha_map | |
| overlay_pil = Image.fromarray(overlay_rgba, mode="RGBA") | |
| buf_overlay = io.BytesIO() | |
| overlay_pil.save(buf_overlay, format="PNG") | |
| overlay_b64 = base64.b64encode(buf_overlay.getvalue()).decode("utf-8") | |
| # Frequency bands | |
| bands = self.model.decomposer(tensor) | |
| band_mags = [float(b.abs().mean().item()) for b in bands] | |
| band_dict = { | |
| f"band_{i+1}": round(band_mags[i], 4) for i in range(len(band_mags)) | |
| } | |
| elapsed_ms = (time.time() - start) * 1000.0 | |
| # Pre-flight domain inspection (draw.io, screenshots, blank canvas, anime/art, social media compression) | |
| ood_info = check_photographic_domain(image, filename=filename) | |
| domain_status = ood_info["domain_status"] | |
| is_non_photographic = ood_info["is_non_photographic_graphic"] | |
| is_sm_compressed = ood_info.get("is_social_media_compressed", False) | |
| blockiness = ood_info.get("compression_blockiness", 1.0) | |
| warning = ood_info["warning"] | |
| # EXIF Hardware & Software metadata audit | |
| exif_res = extract_exif_metadata(image) | |
| if exif_res["is_generative_exif"]: | |
| gen_tag_msg = f"AI Generation Metadata Detected in EXIF ({exif_res['generative_software_tag']})" | |
| warning = f"{warning} | {gen_tag_msg}" if warning else gen_tag_msg | |
| conf = round(probs[pred_idx].item(), 4) | |
| if conf < 0.60: | |
| entropy_msg = f"Low triage certainty ({conf * 100:.1f}%). Model exhibits high entropy across forensic classes." | |
| warning = f"{warning} | {entropy_msg}" if warning else entropy_msg | |
| return { | |
| "prediction": prediction_label, | |
| "confidence": conf, | |
| "tampering_detected": pred_idx in [1, 2], | |
| "class_probabilities": class_probs, | |
| "tampering_heatmap_b64": mask_b64, | |
| "tampering_overlay_b64": overlay_b64, | |
| "tampering_peak_score": peak_score, | |
| "tampering_coverage_ratio": coverage_ratio, | |
| "frequency_band_contributions": band_dict, | |
| "processing_time_ms": round(elapsed_ms, 2), | |
| "model_variant": self.variant, | |
| "is_non_photographic_graphic": is_non_photographic, | |
| "domain_status": domain_status, | |
| "is_social_media_compressed": is_sm_compressed, | |
| "compression_blockiness": blockiness, | |
| "consensus_status": "single_model", | |
| "consensus_message": None, | |
| "exif_summary": exif_res["exif_summary"], | |
| "generative_software_tag": exif_res["generative_software_tag"], | |
| "sha256_hash": sha256_hash, | |
| "cached": False, | |
| "warning": warning, | |
| } | |
| def predict_ensemble( | |
| server_base: TamperingModelServer, | |
| server_large: TamperingModelServer, | |
| image: Image.Image, | |
| filename: Optional[str] = None, | |
| sha256_hash: Optional[str] = None, | |
| ) -> Dict[str, Any]: | |
| """ | |
| SOTA Conservative Forensic Fusion Ensemble combining Patch-MFFT Base (3.28M) | |
| and Patch-MFFT Large (13.94M). Applies tampering priority gating and element-wise | |
| maximum likelihood localization mask fusion. | |
| """ | |
| start = time.time() | |
| res_b = server_base.predict(image, filename=filename, sha256_hash=sha256_hash) | |
| res_l = server_large.predict(image, filename=filename, sha256_hash=sha256_hash) | |
| pb = res_b["class_probabilities"] | |
| pl = res_l["class_probabilities"] | |
| # Conservative Forensic Fusion (Tampering Priority Gating) | |
| # If Base detected localized inpainting (p_spliced > 0.45), preserve localized tampering | |
| if pb["spliced_tampered"] > 0.45: | |
| prediction_label = "spliced_tampered" | |
| conf = pb["spliced_tampered"] | |
| # If Large or Base detected Full AI with high confidence, preserve generative evidence | |
| elif pl["full_ai"] > 0.70 or pb["full_ai"] > 0.70: | |
| prediction_label = "full_ai" | |
| conf = max(pl["full_ai"], pb["full_ai"]) | |
| else: | |
| ens_auth = 0.40 * pb["authentic"] + 0.60 * pl["authentic"] | |
| ens_ai = 0.40 * pb["full_ai"] + 0.60 * pl["full_ai"] | |
| ens_splice = 0.40 * pb["spliced_tampered"] + 0.60 * pl["spliced_tampered"] | |
| candidates = [("authentic", ens_auth), ("full_ai", ens_ai), ("spliced_tampered", ens_splice)] | |
| candidates.sort(key=lambda x: x[1], reverse=True) | |
| prediction_label = candidates[0][0] | |
| conf = candidates[0][1] | |
| # Consensus Status | |
| if res_b["prediction"] == res_l["prediction"]: | |
| consensus_status = "verified_consensus" | |
| consensus_message = f"Multi-Model Consensus: Both Base ({res_b['confidence']*100:.1f}%) and Large ({res_l['confidence']*100:.1f}%) agree unanimously on '{prediction_label}'." | |
| else: | |
| consensus_status = "disputed" | |
| consensus_message = ( | |
| f"Disputed Forensic Case: Base ({res_b['prediction']}, {res_b['confidence']*100:.1f}%) " | |
| f"vs Large ({res_l['prediction']}, {res_l['confidence']*100:.1f}%). " | |
| f"Conservative forensic fusion prioritized the higher tampering indicator." | |
| ) | |
| # Element-Wise Maximum Likelihood Heatmap Fusion | |
| t_b = server_base._preprocess(image) | |
| t_l = server_large._preprocess(image) | |
| with torch.no_grad(): | |
| _, mask_b = server_base.model(t_b, return_mask=True) | |
| _, mask_l = server_large.model(t_l, return_mask=True) | |
| m_b = torch.sigmoid(mask_b)[0, 0].cpu().numpy() | |
| m_l = torch.sigmoid(mask_l)[0, 0].cpu().numpy() | |
| fused_mask = np.maximum(m_b, m_l) | |
| peak_score = round(float(fused_mask.max()), 4) | |
| coverage_ratio = round(float(np.mean(fused_mask > 0.50)), 4) | |
| lut_idx = np.clip(fused_mask * 255.0, 0, 255).astype(np.int32) | |
| heatmap_rgb = TURBO_LUT[lut_idx] | |
| mask_pil = Image.fromarray(heatmap_rgb, mode="RGB") | |
| buf = io.BytesIO() | |
| mask_pil.save(buf, format="PNG") | |
| mask_b64 = base64.b64encode(buf.getvalue()).decode("utf-8") | |
| overlay_rgba = np.zeros((server_base.img_size, server_base.img_size, 4), dtype=np.uint8) | |
| overlay_rgba[:, :, :3] = heatmap_rgb | |
| alpha_map = np.clip((fused_mask - 0.12) / 0.25 * 210.0, 0, 210).astype(np.uint8) | |
| overlay_rgba[:, :, 3] = alpha_map | |
| overlay_pil = Image.fromarray(overlay_rgba, mode="RGBA") | |
| buf_o = io.BytesIO() | |
| overlay_pil.save(buf_o, format="PNG") | |
| overlay_b64 = base64.b64encode(buf_o.getvalue()).decode("utf-8") | |
| class_probs = { | |
| "authentic": round(0.40 * pb["authentic"] + 0.60 * pl["authentic"], 4), | |
| "full_ai": round(0.40 * pb["full_ai"] + 0.60 * pl["full_ai"], 4), | |
| "spliced_tampered": round(0.40 * pb["spliced_tampered"] + 0.60 * pl["spliced_tampered"], 4), | |
| } | |
| warning = res_b.get("warning") or res_l.get("warning") | |
| if consensus_status == "disputed": | |
| warning = f"{warning} | {consensus_message}" if warning else consensus_message | |
| elapsed_ms = (time.time() - start) * 1000.0 | |
| return { | |
| "prediction": prediction_label, | |
| "confidence": round(float(conf), 4), | |
| "tampering_detected": prediction_label in ["full_ai", "spliced_tampered"], | |
| "class_probabilities": class_probs, | |
| "tampering_heatmap_b64": mask_b64, | |
| "tampering_overlay_b64": overlay_b64, | |
| "tampering_peak_score": peak_score, | |
| "tampering_coverage_ratio": coverage_ratio, | |
| "frequency_band_contributions": res_l["frequency_band_contributions"], | |
| "processing_time_ms": round(elapsed_ms, 2), | |
| "model_variant": "ensemble", | |
| "is_non_photographic_graphic": res_b["is_non_photographic_graphic"], | |
| "domain_status": res_b["domain_status"], | |
| "is_social_media_compressed": res_b["is_social_media_compressed"], | |
| "compression_blockiness": res_b["compression_blockiness"], | |
| "consensus_status": consensus_status, | |
| "consensus_message": consensus_message, | |
| "exif_summary": res_b.get("exif_summary"), | |
| "generative_software_tag": res_b.get("generative_software_tag"), | |
| "sha256_hash": sha256_hash, | |
| "cached": False, | |
| "warning": warning, | |
| } | |