File size: 14,637 Bytes
7104b2c
 
 
 
34ad4eb
7104b2c
 
 
34ad4eb
 
 
7104b2c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
import numpy as np
import cv2
import torch
import os
import logging
from PIL import Image, ImageFilter, ImageStat
from typing import Tuple, Optional

# Configure local logger for audit fixes
logger = logging.getLogger("ForensicAuditor")


class ForensicAuditorFixes:
    """
    Consolidated forensic processing library.
    v3: Improved face detection, better ELA, enhanced frequency analysis,
         noise consistency checks, and chromatic aberration detection.
    """

    # ==========================================
    # SECTION 1: Input Sanitization
    # ==========================================
    @staticmethod
    def sanitize_image(image: Image.Image) -> Image.Image:
        """
        Handles CMYK, RGBA, Grayscale, and P-mode images securely.
        Prevents black-alpha artifacts on RGBA composites.
        """
        if image.mode == 'RGBA':
            background = Image.new('RGB', image.size, (255, 255, 255))
            background.paste(image, mask=image.split()[3])
            image = background
        elif image.mode == 'P':
            image = image.convert('RGBA')
            background = Image.new('RGB', image.size, (255, 255, 255))
            background.paste(image, mask=image.split()[3])
            image = background
        elif image.mode != 'RGB':
            image = image.convert('RGB')

        w, h = image.size
        if w < 64 or h < 64:
            raise ValueError(f"IMAGE_TOO_SMALL: {w}x{h} — minimum 64px for forensic analysis.")
        if w > 8000 or h > 8000:
            image.thumbnail((8000, 8000), Image.Resampling.LANCZOS)

        return image

    @staticmethod
    def sanitize_filename(filename: str) -> str:
        if not filename:
            return "scrubbed_asset.unknown"
        clean = "".join([c for c in filename if c.isalnum() or c in (' ', '.', '_', '-')]).rstrip()
        return clean if clean else "safe_asset_rename.unknown"

    # ==========================================
    # SECTION 2: Face-Aware Region Extraction
    # ==========================================
    @staticmethod
    def extract_face_region(image: Image.Image) -> Tuple[Image.Image, bool]:
        """
        Detects faces using OpenCV DNN face detector (much better than Haar).
        Falls back to Haar if DNN model not available.
        Returns (cropped_face_or_full_image, face_found).
        """
        arr = np.array(image.convert("RGB"))
        h, w = arr.shape[:2]

        # Try DNN face detector first (much more accurate)
        face_box = ForensicAuditorFixes._detect_face_dnn(arr)

        if face_box is None:
            # Fallback to Haar cascade
            face_box = ForensicAuditorFixes._detect_face_haar(arr)

        if face_box is None:
            return image, False

        x, y, fw, fh = face_box

        # Add 40% padding around face for context
        pad_x = int(fw * 0.40)
        pad_y = int(fh * 0.40)
        x1 = max(0, x - pad_x)
        y1 = max(0, y - pad_y)
        x2 = min(w, x + fw + pad_x)
        y2 = min(h, y + fh + pad_y)

        face_crop = arr[y1:y2, x1:x2]
        return Image.fromarray(face_crop), True

    @staticmethod
    def _detect_face_dnn(arr: np.ndarray) -> Optional[Tuple[int, int, int, int]]:
        """Use OpenCV DNN face detector if available."""
        try:
            h, w = arr.shape[:2]
            # Use OpenCV's built-in DNN detector if available
            prototxt = os.path.join(cv2.data.haarcascades, "..", "dnn", "face_detector", "deploy.prototxt")
            caffemodel = os.path.join(cv2.data.haarcascades, "..", "dnn", "face_detector", "res10_300x300_ssd_iter_140000.caffemodel")

            if not os.path.exists(prototxt) or not os.path.exists(caffemodel):
                logger.debug("DNN face detector: model files not found — using Haar cascade fallback.")
                return None

            net = cv2.dnn.readNetFromCaffe(prototxt, caffemodel)
            blob = cv2.dnn.blobFromImage(cv2.cvtColor(arr, cv2.COLOR_RGB2BGR), 1.0, (300, 300), [104.0, 177.0, 123.0])
            net.setInput(blob)
            detections = net.forward()

            best_conf = 0.0
            best_box = None
            for i in range(detections.shape[2]):
                confidence = detections[0, 0, i, 2]
                if confidence > 0.5 and confidence > best_conf:
                    best_conf = confidence
                    box = detections[0, 0, i, 3:7] * np.array([w, h, w, h])
                    x1, y1, x2, y2 = box.astype(int)
                    best_box = (x1, y1, x2 - x1, y2 - y1)

            return best_box
        except Exception:
            return None

    @staticmethod
    def _detect_face_haar(arr: np.ndarray) -> Optional[Tuple[int, int, int, int]]:
        """Legacy Haar cascade fallback."""
        gray = cv2.cvtColor(arr, cv2.COLOR_RGB2GRAY)
        cascade_path = cv2.data.haarcascades + "haarcascade_frontalface_default.xml"
        face_cascade = cv2.CascadeClassifier(cascade_path)

        faces = face_cascade.detectMultiScale(
            gray,
            scaleFactor=1.1,
            minNeighbors=4,
            minSize=(48, 48),
            flags=cv2.CASCADE_SCALE_IMAGE
        )

        if len(faces) == 0:
            return None

        return max(faces, key=lambda f: f[2] * f[3])

    # ==========================================
    # SECTION 3: Neural Stability
    # ==========================================
    @staticmethod
    def stable_softmax(logits: torch.Tensor, temperature: float = 1.0) -> torch.Tensor:
        logits_f32 = logits.to(torch.float32) / temperature
        return torch.nn.functional.softmax(logits_f32, dim=-1)

    # ==========================================
    # SECTION 4: Multi-Pass ELA (Calibrated v3)
    # ==========================================
    @staticmethod
    def compute_robust_ela(image: Image.Image, quality: int = 90) -> Tuple[Image.Image, float]:
        """
        Improved ELA with better calibration and PNG-aware processing.
        """
        rgb_arr = np.array(image)[:, :, ::-1].copy()

        diff_maps = []
        pass_means = []

        for q in (75, 90, 95):
            _, encimg = cv2.imencode(".jpg", rgb_arr, [int(cv2.IMWRITE_JPEG_QUALITY), q])
            decimg = cv2.imdecode(encimg, 1)
            diff = np.abs(rgb_arr.astype(np.float32) - decimg.astype(np.float32))
            pass_means.append(float(np.mean(diff)))
            diff_maps.append(diff)

        # Cross-pass variance — AI images tend to have more uniform error
        pass_variance = float(np.std(pass_means))
        variance_score = max(0.0, min(1.0, 1.0 - (pass_variance / 4.0)))

        # Magnitude at q=90 — recalibrated so typical images land mid-range
        mean_ela = pass_means[1]
        # Typical mean_ela: real JPEG ~3-8, AI PNG ~1-3, heavily edited ~10+
        magnitude_score = max(0.0, min(1.0, 1.0 - ((mean_ela - 0.5) / 6.0)))

        # Blend
        ela_score = 0.4 * variance_score + 0.6 * magnitude_score
        ela_score = max(0.0, min(1.0, ela_score))

        # Visualization
        best_map = diff_maps[0][:, :, ::-1]
        max_val = float(np.max(best_map))
        if max_val > 0:
            best_map = best_map / max_val * 255.0

        ela_image = Image.fromarray(best_map.astype(np.uint8))
        return ela_image, float(ela_score)

    # ==========================================
    # SECTION 5: Hanning-Windowed FFT (Enhanced)
    # ==========================================
    @staticmethod
    def compute_robust_fft(image: Image.Image) -> Tuple[Image.Image, float, np.ndarray]:
        """
        Frequency domain analysis with GAN artifact detection.
        """
        gray = cv2.cvtColor(np.array(image), cv2.COLOR_RGB2GRAY).astype(np.float32)
        h, w = gray.shape

        # Hanning Window
        win_y = np.hanning(h)
        win_x = np.hanning(w)
        hanning_2d = np.outer(win_y, win_x)
        windowed = gray * hanning_2d

        fshift = np.fft.fftshift(np.fft.fft2(windowed))
        magnitude = 20 * np.log(np.abs(fshift) + 1e-8)

        # High-frequency ring energy
        cy, cx = h // 2, w // 2
        r_inner = min(h, w) // 8
        r_outer = min(h, w) // 3
        y_idx, x_idx = np.ogrid[:h, :w]
        dist_sq = (x_idx - cx) ** 2 + (y_idx - cy) ** 2
        hf_mask = (dist_sq > r_inner ** 2) & (dist_sq <= r_outer ** 2)
        hf_energy = np.mean(magnitude[hf_mask])
        ring_score = min(max(hf_energy / 160.0, 0.0), 1.0)

        # Periodic peak detection
        flat = magnitude[hf_mask].flatten()
        if len(flat) > 0 and np.std(flat) > 0:
            spike_threshold = np.mean(flat) + 2.5 * np.std(flat)
            spike_fraction = np.sum(flat > spike_threshold) / (len(flat) + 1e-8)
            peak_score = min(spike_fraction * 15.0, 1.0)
        else:
            peak_score = 0.0

        fft_score = 0.35 * ring_score + 0.65 * peak_score
        fft_score = max(0.0, min(1.0, fft_score))

        # Visualization
        mag_norm = cv2.normalize(magnitude, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8)
        fft_colored = cv2.applyColorMap(mag_norm, cv2.COLORMAP_INFERNO)
        fft_image = Image.fromarray(cv2.cvtColor(fft_colored, cv2.COLOR_BGR2RGB))

        return fft_image, float(fft_score), magnitude

    # ==========================================
    # SECTION 6: DCT Block Boundary Analysis
    # ==========================================
    @staticmethod
    def compute_dct_block_score(image: Image.Image) -> float:
        """
        Detects JPEG DCT block boundary artifacts.
        """
        gray = cv2.cvtColor(np.array(image.convert("RGB")), cv2.COLOR_RGB2GRAY)
        gray_f = gray.astype(np.float32)

        # Use Laplacian for sharper edge detection
        grad = cv2.Laplacian(gray_f, cv2.CV_32F)
        gradient_mag = np.abs(grad)

        h, w = gray.shape

        # Sample at 8x8 boundaries
        boundary_rows = [r for r in range(0, h, 8) if 0 < r < h]
        boundary_cols = [c for c in range(0, w, 8) if 0 < c < w]

        boundary_grad = []
        for r in boundary_rows:
            boundary_grad.append(np.mean(gradient_mag[r, :]))
        for c in boundary_cols:
            boundary_grad.append(np.mean(gradient_mag[:, c]))

        # Sample in block interiors
        interior_rows = [r + 4 for r in range(0, h - 4, 8)]
        interior_cols = [c + 4 for c in range(0, w - 4, 8)]

        interior_grad = []
        for r in interior_rows:
            if r < h:
                interior_grad.append(np.mean(gradient_mag[r, :]))
        for c in interior_cols:
            if c < w:
                interior_grad.append(np.mean(gradient_mag[:, c]))

        if not boundary_grad or not interior_grad:
            return 0.5

        boundary_mean = np.mean(boundary_grad)
        interior_mean = np.mean(interior_grad)

        ratio = boundary_mean / (interior_mean + 1e-8)
        score = min(max((ratio - 0.8) / 2.0, 0.0), 1.0)
        return float(score)

    # ==========================================
    # SECTION 7: Noise Consistency Analysis (NEW)
    # ==========================================
    @staticmethod
    def compute_noise_consistency(image: Image.Image) -> float:
        """
        AI-generated images often have unnaturally uniform noise.
        Real camera photos have photon noise that varies with brightness.
        Returns score where high = likely synthetic.
        """
        gray = cv2.cvtColor(np.array(image.convert("RGB")), cv2.COLOR_RGB2GRAY).astype(np.float32)
        h, w = gray.shape

        # Downsample large images to avoid texture dominating
        if max(h, w) > 1024:
            scale = 1024 / max(h, w)
            gray = cv2.resize(gray, (int(w * scale), int(h * scale)))

        # Denoise to isolate noise
        denoised = cv2.medianBlur(gray.astype(np.uint8), 5).astype(np.float32)
        noise = np.abs(gray - denoised)

        # Divide into 4x4 grid and measure noise std in each cell
        cells_h, cells_w = 4, 4
        cell_h, cell_w = gray.shape[0] // cells_h, gray.shape[1] // cells_w
        cell_stds = []
        for i in range(cells_h):
            for j in range(cells_w):
                y1, y2 = i * cell_h, (i + 1) * cell_h
                x1, x2 = j * cell_w, (j + 1) * cell_w
                cell = noise[y1:y2, x1:x2]
                if cell.size > 0:
                    cell_stds.append(np.std(cell))

        if len(cell_stds) < 4:
            return 0.5

        # Real photos: noise std varies across image (lighting, focus, ISO variation)
        # AI images: noise std is very uniform across image
        mean_std = np.mean(cell_stds) + 1e-8
        std_of_stds = np.std(cell_stds)
        uniformity = std_of_stds / mean_std  # CV of noise std across cells

        # Real: uniformity > 0.35 (noise varies spatially)
        # AI: uniformity < 0.15 (noise is unnaturally uniform)
        score = 1.0 - ((uniformity - 0.10) / 0.30)
        score = max(0.0, min(1.0, score))
        return float(score)

    # ==========================================
    # SECTION 8: Edge Sharpness Analysis (NEW)
    # ==========================================
    @staticmethod
    def compute_edge_sharpness(image: Image.Image) -> float:
        """
        AI images often have unnaturally perfect edge transitions.
        Real photos have softer, more variable edges due to lens optics.
        Returns score where high = likely synthetic.
        """
        gray = cv2.cvtColor(np.array(image.convert("RGB")), cv2.COLOR_RGB2GRAY)

        # Sobel gradients
        sobelx = cv2.Sobel(gray, cv2.CV_32F, 1, 0, ksize=3)
        sobely = cv2.Sobel(gray, cv2.CV_32F, 0, 1, ksize=3)
        grad_mag = np.sqrt(sobelx**2 + sobely**2)

        # Find strong edges (top 10% gradient magnitude)
        threshold = np.percentile(grad_mag, 90)
        edge_mask = grad_mag > threshold

        if np.sum(edge_mask) < 100:
            return 0.5

        # Measure how sharp edges are: AI has very narrow transition (high local max)
        # Real photos have softer roll-off
        edge_values = grad_mag[edge_mask]
        
        # Coefficient of variation of edge gradients
        # Real: more variation in edge sharpness (CV ~0.5-1.0)
        # AI: very consistent edge sharpness (CV ~0.2-0.4)
        mean_grad = np.mean(edge_values) + 1e-8
        std_grad = np.std(edge_values)
        cv_grad = std_grad / mean_grad

        # Map: cv < 0.25 -> synthetic (score high), cv > 0.6 -> real (score low)
        score = 1.0 - ((cv_grad - 0.25) / 0.35)
        score = max(0.0, min(1.0, score))
        return float(score)