File size: 10,299 Bytes
e7f1d57
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
# Dependencies
import numpy as np
from utils.logger import get_logger
from config.schemas import MetricResult
from config.constants import MetricType
from utils.image_processor import ImageProcessor
from config.constants import GRADIENT_FIELD_PCA_PARAMS

# Suppress NumPy warning 
np.seterr(divide  = 'ignore', 
          invalid = 'ignore',
         )


# Setup Logging
logger = get_logger(__name__)


class GradientFieldPCADetector:
    """
    Detects AI-generated images by analyzing gradient field consistency. Real photos have consistent gradient 
    patterns shaped by physics (lighting, optics). Diffusion models struggle to maintain physically consistent
    gradients due to denoising
    
    Core principle:
    ---------------
    - Real photos : Gradients align with physical light sources (low-dimensional structure)
    - AI images   : Gradients are inconsistent due to patch-based denoising (high-dimensional)
    
    Method:
    -------
    1. Convert to luminance
    2. Compute Sobel gradients (Gx, Gy)
    3. Flatten to gradient vectors per pixel
    4. Compute covariance matrix
    5. PCA eigenvalue analysis
    """
    def __init__(self):
        """
        Initialize Gradient-Field PCA Detector class
        """
        self._range          = np.random.default_rng(seed = GRADIENT_FIELD_PCA_PARAMS.RANDOM_SEED)
        self.image_processor = ImageProcessor()
    

    def detect(self, image: np.ndarray) -> MetricResult:
        """
        Run gradient PCA detection
        
        Arguments:
        ----------
            image { np.ndarray } : RGB image array (H, W, 3)
        
        Returns:
        --------
            { MetricResult }     : Structured metric result containing:
                                   - score      : Suspicion score [0.0, 1.0] (0 = natural, 1 = suspicious)
                                   - confidence : Confidence of this metric's assessment [0.0, 1.0]
                                   - details    : Explainability metadata for UI and reports
        """
        try:
            logger.debug(f"Running gradient PCA detection on image shape {image.shape}")
            
            # Convert image to luminance
            luminance             = self.image_processor.rgb_to_luminance(image = image)
            
            # Compute gradients
            gx, gy                = self.image_processor.compute_gradients(luminance = luminance)
            
            # Flatten and sample gradient vectors
            gradient_vectors      = self._prepare_and_sample_gradients(gx = gx, 
                                                                       gy = gy,
                                                                      )
            
            # Perform PCA
            eigenvalue_ratio      = self._compute_eigenvalue_ratio(gradient_vectors = gradient_vectors)

            if ((len(gradient_vectors) < GRADIENT_FIELD_PCA_PARAMS.MIN_SAMPLES) or (eigenvalue_ratio == GRADIENT_FIELD_PCA_PARAMS.NEUTRAL_SCORE)):
                return MetricResult(metric_type = MetricType.GRADIENT,
                                    score       = GRADIENT_FIELD_PCA_PARAMS.NEUTRAL_SCORE,
                                    confidence  = 0.0,
                                    details     = {"reason"           : "insufficient_gradient_information",
                                                   "original_pixels"  : int(gx.size),
                                                   "filtered_vectors" : int(len(gradient_vectors)),
                                                  },
                                   )
            
            # Convert to suspicion score
            suspicion_score       = self._eigenvalue_to_suspicion(eigenvalue_ratio = eigenvalue_ratio)

            # Confidence inverted relative to suspicion: High eigenvalue_ratio = natural, High suspicion_score = AI-like
            confidence            = abs(eigenvalue_ratio - GRADIENT_FIELD_PCA_PARAMS.EIGENVALUE_RATIO_THRESHOLD)
            normalized_confidence = np.clip((confidence / GRADIENT_FIELD_PCA_PARAMS.EIGENVALUE_RATIO_THRESHOLD), 0.0, 1.0)
            
            logger.debug(f"Gradient PCA: eigenvalue_ratio={eigenvalue_ratio:.3f}, suspicion_score={suspicion_score:.3f}")
            
            return MetricResult(metric_type = MetricType.GRADIENT,
                                score       = float(suspicion_score),
                                confidence  = float(normalized_confidence),
                                details     = {"gradient_vectors_sampled" : len(gradient_vectors),
                                               "eigenvalue_ratio"         : float(eigenvalue_ratio),
                                               "threshold"                : GRADIENT_FIELD_PCA_PARAMS.EIGENVALUE_RATIO_THRESHOLD,
                                               "original_pixels"          : int(gx.size),
                                               "filtered_vectors"         : int(len(gradient_vectors)),
                                              },
                               )
            
        except Exception as e:
            logger.error(f"Gradient PCA detection failed: {e}")
            
            # Return neutral score on error
            return MetricResult(metric_type = MetricType.GRADIENT,
                                score       = GRADIENT_FIELD_PCA_PARAMS.NEUTRAL_SCORE,
                                confidence  = 0.0,
                                details     = {"error" : "Gradient PCA detection failed"},
                               )
    

    def _prepare_and_sample_gradients(self, gx: np.ndarray, gy: np.ndarray) -> np.ndarray:
        """
        Flatten gradients into vectors and sample
        
        Arguments:
        ----------
            gx { np.ndarray } : Gradient in x direction

            gy { np.ndarray } : Gradient in y direction
        
        Returns:
        --------
            { np.ndarray }    : Array of gradient vectors (N, 2) where N <= SAMPLE_SIZE
        """
        # Flatten to vectors
        gx_flat                   = gx.flatten()
        gy_flat                   = gy.flatten()

        # Stack into (N, 2) array
        gradient_vectors          = np.stack([gx_flat, gy_flat], axis = 1)
        original_n                = len(gradient_vectors)

        # Remove zero gradients (uniform regions)
        magnitude                 = np.linalg.norm(gradient_vectors, axis = 1)
        non_zero_mask             = (magnitude > GRADIENT_FIELD_PCA_PARAMS.MAGNITUDE_THRESHOLD)
        finite_mask               = np.isfinite(gradient_vectors).all(axis = 1)

        # Filtering Gradient Vector
        filtered_gradient_vectors = gradient_vectors[non_zero_mask & finite_mask]
        filtered_n                = len(filtered_gradient_vectors)
        
        # Sample if too many points without replacement
        if (len(filtered_gradient_vectors) > GRADIENT_FIELD_PCA_PARAMS.SAMPLE_SIZE):
            indices                   = self._range.choice(a       = len(filtered_gradient_vectors), 
                                                           size    = GRADIENT_FIELD_PCA_PARAMS.SAMPLE_SIZE, 
                                                           replace = False,
                                                          )

            sampled_gradient_vectors  = filtered_gradient_vectors[indices]
        
        else:
            sampled_gradient_vectors  = filtered_gradient_vectors


        sampled_n = len(sampled_gradient_vectors)

        logger.debug(f"Gradient PCA sampling: original={original_n}, filtered={filtered_n}, sampled={sampled_n}")
        
        return sampled_gradient_vectors
    

    def _compute_eigenvalue_ratio(self, gradient_vectors: np.ndarray) -> float:
        """
        Compute ratio of first eigenvalue to total variance
        
        -  Lower ratio  = more diffuse structure = suspicious
        -  Higher ratio = concentrated structure = natural
        
        Arguments:
        ----------
            gradient_vectors { np.ndarray } : Array of gradient vectors (N, 2)
        
        Returns:
        --------
                     { float }              : Ratio of first eigenvalue to sum of eigenvalues
        """
        if (len(gradient_vectors) < GRADIENT_FIELD_PCA_PARAMS.MIN_SAMPLES):
            logger.warning("Insufficient gradient samples for PCA")
            return GRADIENT_FIELD_PCA_PARAMS.NEUTRAL_SCORE
        
        # Compute covariance matrix
        covariance       = np.cov(m    = gradient_vectors.T,
                                  bias = True,
                                 )
        
        # Compute eigenvalues
        eigenvalues      = np.linalg.eigvalsh(covariance)

        # Sort in descending order
        eigenvalues      = np.sort(eigenvalues)[::-1]  
        
        # Ratio of largest eigenvalue to sum
        total_variance   = np.sum(eigenvalues)
        
        if (total_variance < GRADIENT_FIELD_PCA_PARAMS.VARIANCE_THRESHOLD):
            return GRADIENT_FIELD_PCA_PARAMS.NEUTRAL_SCORE
        
        eigenvalue_ratio = eigenvalues[0] / total_variance

        return float(eigenvalue_ratio)
    

    def _eigenvalue_to_suspicion(self, eigenvalue_ratio: float) -> float:
        """
        Convert eigenvalue ratio to suspicion score
        
        - Real photos : High ratio (0.85-0.95) -> Low suspicion
        - AI images   : Low ratio (0.50-0.75) -> High suspicion
        
        Arguments:
        ----------
            eigenvalue_ratio { float } : PCA eigenvalue ratio
        
        Returns:
        --------
                    { float }          : Suspicion score [0.0, 1.0]
        """
        # Invert and scale: higher ratio = lower suspicion
        # Real photos typically have ratio > 0.85 & AI images typically have ratio < 0.75
        if (eigenvalue_ratio >= GRADIENT_FIELD_PCA_PARAMS.EIGENVALUE_RATIO_THRESHOLD):
            # Strong gradient alignment = likely real
            suspicion = max(0.0, (1.0 - eigenvalue_ratio) * 2.0)

        else:
            # Weak alignment = suspicious
            suspicion = 1.0 - (eigenvalue_ratio / GRADIENT_FIELD_PCA_PARAMS.EIGENVALUE_RATIO_THRESHOLD)
        
        return float(np.clip(suspicion, 0.0, 1.0))