abinazebinoy commited on
Commit
df73d76
·
unverified ·
1 Parent(s): 708d3cf

Fix AI detector stability, serialization, and test accuracy (#13)

Browse files

Fixes applied:
- Guard corrupted images (cv2.imdecode None check)
- NaN protection in JPEG blockiness (empty list guard)
- numpy.bool_ wrapped with bool() for Pydantic serialization
- Dynamic FFT center_size: min(30, crow, ccol) for small images
- float() on all numpy scalars in return dicts

Test fixes:
- Replace 1x1px fixture with 100x100px gradient + Gaussian noise
- Update size_mb assertion from < 0.01 to < 1.0

Result: 25/25 tests passing

backend/services/ai_detector.py ADDED
@@ -0,0 +1,357 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ AI-generated image detection service.
3
+ Uses statistical analysis and heuristics to detect AI-generated images.
4
+
5
+ Detection Signals:
6
+ 1. Noise pattern consistency - Sensor noise modeling (Laplacian variance)
7
+ 2. Frequency domain analysis - FFT spectral fingerprinting
8
+ 3. JPEG compression artifacts - DCT block boundary detection
9
+ 4. Color distribution entropy - HSV histogram analysis
10
+
11
+ Mathematical basis:
12
+ - Noise: Consistency = σ_local / μ_local (lower = suspicious)
13
+ - Frequency: Ratio = LowFreqEnergy / HighFreqEnergy
14
+ - Entropy: H(X) = -Σ p(x)log p(x)
15
+ """
16
+ import numpy as np
17
+ import cv2
18
+ from scipy import fft
19
+ from scipy.stats import entropy
20
+ from typing import Dict, Any
21
+ from PIL import Image
22
+ from io import BytesIO
23
+
24
+ from backend.core.logger import setup_logger
25
+
26
+ logger = setup_logger(__name__)
27
+
28
+
29
+ class AIDetector:
30
+ """
31
+ AI-generated image detector using statistical analysis.
32
+
33
+ Why statistical approach?
34
+ - No heavy model downloads required
35
+ - Fast inference (< 1 second)
36
+ - Interpretable signal breakdown
37
+ - Works fully offline
38
+ """
39
+
40
+ def __init__(self, image_bytes: bytes, filename: str):
41
+ """
42
+ Initialize detector with image data.
43
+
44
+ Args:
45
+ image_bytes: Raw image file content
46
+ filename: Original filename for logging
47
+
48
+ Raises:
49
+ ValueError: If image is corrupted or unreadable
50
+ """
51
+ self.image_bytes = image_bytes
52
+ self.filename = filename
53
+
54
+ # Load via PIL (for metadata-aware loading)
55
+ self.pil_image = Image.open(BytesIO(image_bytes))
56
+
57
+ # Load via OpenCV (for numerical analysis)
58
+ self.cv_image = cv2.imdecode(
59
+ np.frombuffer(image_bytes, np.uint8),
60
+ cv2.IMREAD_COLOR
61
+ )
62
+
63
+
64
+ if self.cv_image is None:
65
+ raise ValueError(f"Invalid or corrupted image file: {filename}")
66
+
67
+ self.cv_gray = cv2.cvtColor(self.cv_image, cv2.COLOR_BGR2GRAY)
68
+
69
+ logger.info(f"Initialized AI detector for {filename} "
70
+ f"({self.cv_gray.shape[1]}x{self.cv_gray.shape[0]}px)")
71
+
72
+ def analyze_noise_patterns(self) -> Dict[str, Any]:
73
+ """
74
+ Analyze noise patterns using Laplacian operator.
75
+
76
+ Mathematical basis:
77
+ L(x,y) = ∇²I(x,y) (second derivative = high freq noise)
78
+ Consistency = σ_local / μ_local
79
+
80
+ Real photos: Noise ~ N(0, σ²) - natural Gaussian sensor noise
81
+ AI images: Low stochastic variation → lower variance diversity
82
+
83
+ Returns:
84
+ Dictionary with noise metrics
85
+ """
86
+ # Laplacian extracts high-frequency noise components
87
+ laplacian = cv2.Laplacian(self.cv_gray, cv2.CV_64F)
88
+ noise_variance = laplacian.var()
89
+
90
+ # Local variance analysis (real photos have higher local diversity)
91
+ kernel_size = 5
92
+ img_float = self.cv_gray.astype(float)
93
+ mean_local = cv2.blur(img_float, (kernel_size, kernel_size))
94
+ sqr_mean = cv2.blur(img_float ** 2, (kernel_size, kernel_size))
95
+ local_variance = sqr_mean - mean_local ** 2
96
+
97
+ local_var_mean = local_variance.mean()
98
+ local_var_std = local_variance.std()
99
+
100
+ # Consistency ratio: lower = more uniform = more suspicious
101
+ noise_consistency = local_var_std / (local_var_mean + 1e-10)
102
+
103
+ logger.info(
104
+ f"Noise analysis: variance={noise_variance:.2f}, "
105
+ f"consistency={noise_consistency:.4f}"
106
+ )
107
+
108
+ return {
109
+ "noise_variance": float(noise_variance),
110
+ "local_variance_mean": float(local_var_mean),
111
+ "noise_consistency": float(noise_consistency),
112
+ "suspicious": bool(noise_consistency < 0.3)
113
+ }
114
+
115
+ def analyze_frequency_domain(self) -> Dict[str, Any]:
116
+ """
117
+ Analyze frequency domain via 2D FFT.
118
+
119
+ Mathematical basis:
120
+ F(u,v) = Σ I(x,y) · e^(-j2π(ux+vy))
121
+ Ratio = LowFreqEnergy / HighFreqEnergy
122
+ H(X) = -Σ p(x)log p(x) (spectral entropy)
123
+
124
+ Real photos: Energy decays gradually with frequency
125
+ AI images: Abnormal high-frequency spikes or flat spectrum
126
+
127
+ Returns:
128
+ Dictionary with frequency metrics
129
+ """
130
+ # 2D Fast Fourier Transform
131
+ f_transform = fft.fft2(self.cv_gray)
132
+ f_shift = fft.fftshift(f_transform) # Zero frequency to center
133
+ magnitude_spectrum = np.abs(f_shift)
134
+
135
+ rows, cols = self.cv_gray.shape
136
+ crow, ccol = rows // 2, cols // 2
137
+
138
+ # If center_size=30 on a 40x40 image → index goes out of bounds
139
+ center_size = min(30, crow, ccol)
140
+
141
+ # Low freq = center region, High freq = everything else
142
+ low_freq = magnitude_spectrum[
143
+ crow - center_size:crow + center_size,
144
+ ccol - center_size:ccol + center_size
145
+ ].sum()
146
+ high_freq = magnitude_spectrum.sum() - low_freq
147
+ freq_ratio = low_freq / (high_freq + 1e-10)
148
+
149
+ # Spectral entropy: lower = less natural frequency distribution
150
+ spectrum_flat = magnitude_spectrum.flatten()
151
+ spectrum_normalized = spectrum_flat / (spectrum_flat.sum() + 1e-10)
152
+ spectral_entropy = float(entropy(spectrum_normalized + 1e-10))
153
+
154
+ logger.info(
155
+ f"Frequency analysis: ratio={freq_ratio:.4f}, "
156
+ f"entropy={spectral_entropy:.2f}"
157
+ )
158
+
159
+ return {
160
+ "frequency_ratio": float(freq_ratio),
161
+ "spectral_entropy": spectral_entropy,
162
+ "suspicious": bool(freq_ratio > 15.0)
163
+ }
164
+
165
+ def analyze_jpeg_artifacts(self) -> Dict[str, Any]:
166
+ """
167
+ Analyze JPEG DCT block boundary artifacts.
168
+
169
+ Mathematical basis:
170
+ JPEG uses 8x8 DCT blocks with quantization
171
+ Block discontinuity = boundary artifact strength
172
+
173
+ Real photos: Authentic JPEG compression boundary patterns
174
+ AI images: Often over-smoothed or lack realistic artifacts
175
+
176
+ Returns:
177
+ Dictionary with JPEG metrics
178
+ """
179
+ blockiness_scores = []
180
+
181
+ for i in range(0, self.cv_gray.shape[0] - 8, 8):
182
+ for j in range(0, self.cv_gray.shape[1] - 8, 8):
183
+ block = self.cv_gray[i:i + 8, j:j + 8].astype(float)
184
+ v_diff = np.abs(block[:, 7] - block[:, 0]).mean()
185
+ h_diff = np.abs(block[7, :] - block[0, :]).mean()
186
+ blockiness_scores.append(v_diff + h_diff)
187
+
188
+ # np.mean([]) returns nan which breaks probability math downstream
189
+ blockiness = float(np.mean(blockiness_scores)) if blockiness_scores else 0.0
190
+
191
+ # Edge density: lower = smoother = more suspicious
192
+ edges = cv2.Canny(self.cv_gray, 100, 200)
193
+ edge_density = float(
194
+ edges.sum() / (self.cv_gray.shape[0] * self.cv_gray.shape[1])
195
+ )
196
+
197
+ logger.info(
198
+ f"JPEG analysis: blockiness={blockiness:.2f}, "
199
+ f"edge_density={edge_density:.6f}"
200
+ )
201
+
202
+ return {
203
+ "blockiness": blockiness,
204
+ "edge_density": edge_density,
205
+ "suspicious": bool(blockiness < 2.0 or edge_density < 0.01)
206
+ }
207
+
208
+ def analyze_color_distribution(self) -> Dict[str, Any]:
209
+ """
210
+ Analyze color distribution via HSV histogram entropy.
211
+
212
+ Mathematical basis:
213
+ H(X) = -Σ p(x)log p(x) applied to hue histogram
214
+ Lower entropy = less color diversity = more suspicious
215
+
216
+ Real photos: Natural color variance and distribution
217
+ AI images: Sometimes oversaturated or unnaturally uniform
218
+
219
+ Returns:
220
+ Dictionary with color metrics
221
+ """
222
+ # HSV separates color (H), saturation (S), brightness (V)
223
+ hsv = cv2.cvtColor(self.cv_image, cv2.COLOR_BGR2HSV)
224
+
225
+ h_var = float(hsv[:, :, 0].var())
226
+ s_var = float(hsv[:, :, 1].var())
227
+ v_var = float(hsv[:, :, 2].var())
228
+
229
+ # Hue histogram entropy
230
+ hist_h = cv2.calcHist([hsv], [0], None, [180], [0, 180])
231
+ hist_normalized = hist_h / (hist_h.sum() + 1e-10)
232
+ color_entropy = float(entropy(hist_normalized.flatten() + 1e-10))
233
+
234
+ mean_saturation = float(hsv[:, :, 1].mean())
235
+
236
+ logger.info(
237
+ f"Color analysis: entropy={color_entropy:.2f}, "
238
+ f"sat={mean_saturation:.2f}"
239
+ )
240
+
241
+ return {
242
+ "hue_variance": h_var,
243
+ "saturation_variance": s_var,
244
+ "value_variance": v_var,
245
+ "color_entropy": color_entropy,
246
+ "mean_saturation": mean_saturation,
247
+ "suspicious": bool(mean_saturation > 150)
248
+ }
249
+
250
+ def calculate_ai_probability(self, signals: Dict[str, Dict]) -> float:
251
+ """
252
+ Combine detection signals into single probability score.
253
+
254
+ Weighted ensemble of normalized signals.
255
+ Weights reflect empirical reliability of each signal.
256
+
257
+ Args:
258
+ signals: All detection signal dictionaries
259
+
260
+ Returns:
261
+ float: AI probability 0.0 (authentic) → 1.0 (AI-generated)
262
+ """
263
+ suspicious_count = sum([
264
+ signals["noise"]["suspicious"],
265
+ signals["frequency"]["suspicious"],
266
+ signals["jpeg"]["suspicious"],
267
+ signals["color"]["suspicious"]
268
+ ])
269
+
270
+ weights = {
271
+ "noise_consistency": 0.25,
272
+ "frequency_ratio": 0.25,
273
+ "blockiness": 0.20,
274
+ "color_entropy": 0.15,
275
+ "edge_density": 0.15
276
+ }
277
+
278
+ # Normalize each signal to [0, 1] where 1 = most suspicious
279
+ normalized_scores = {
280
+ "noise_consistency": max(0.0, 1.0 - signals["noise"]["noise_consistency"] / 0.5),
281
+ "frequency_ratio": min(1.0, signals["frequency"]["frequency_ratio"] / 20.0),
282
+ "blockiness": max(0.0, 1.0 - signals["jpeg"]["blockiness"] / 5.0),
283
+ "color_entropy": max(0.0, 1.0 - signals["color"]["color_entropy"] / 5.0),
284
+ "edge_density": max(0.0, 1.0 - signals["jpeg"]["edge_density"] / 0.05)
285
+ }
286
+
287
+ probability = sum(
288
+ score * weights[name]
289
+ for name, score in normalized_scores.items()
290
+ )
291
+
292
+ # Boost confidence when multiple independent signals agree
293
+ if suspicious_count >= 3:
294
+ probability = min(1.0, probability * 1.2)
295
+
296
+ logger.info(
297
+ f"AI probability: {probability:.3f} "
298
+ f"({suspicious_count}/4 signals suspicious)"
299
+ )
300
+
301
+ return float(probability)
302
+
303
+ def detect(self) -> Dict[str, Any]:
304
+ """
305
+ Run complete AI detection pipeline.
306
+
307
+ Returns:
308
+ Comprehensive detection report as JSON-serializable dict
309
+ """
310
+ logger.info(f"Starting AI detection for {self.filename}")
311
+
312
+ # Run all 4 independent detection signals
313
+ noise_signals = self.analyze_noise_patterns()
314
+ freq_signals = self.analyze_frequency_domain()
315
+ jpeg_signals = self.analyze_jpeg_artifacts()
316
+ color_signals = self.analyze_color_distribution()
317
+
318
+ all_signals = {
319
+ "noise": noise_signals,
320
+ "frequency": freq_signals,
321
+ "jpeg": jpeg_signals,
322
+ "color": color_signals
323
+ }
324
+
325
+ ai_probability = self.calculate_ai_probability(all_signals)
326
+
327
+ # Classify based on probability threshold
328
+ if ai_probability > 0.7:
329
+ classification = "likely_ai_generated"
330
+ confidence = "high"
331
+ elif ai_probability > 0.4:
332
+ classification = "possibly_ai_generated"
333
+ confidence = "medium"
334
+ else:
335
+ classification = "likely_authentic"
336
+ confidence = "high" if ai_probability < 0.2 else "medium"
337
+
338
+ report = {
339
+ "ai_probability": ai_probability,
340
+ "classification": classification,
341
+ "confidence": confidence,
342
+ "detection_signals": all_signals,
343
+ "summary": {
344
+ # int() ensures JSON-serializable (not numpy.int64)
345
+ "suspicious_signals_count": int(sum(
346
+ s["suspicious"] for s in all_signals.values()
347
+ )),
348
+ "total_signals": len(all_signals)
349
+ }
350
+ }
351
+
352
+ logger.info(
353
+ f"Detection complete: {classification} "
354
+ f"(probability={ai_probability:.3f})"
355
+ )
356
+
357
+ return report
backend/tests/conftest.py CHANGED
@@ -1,31 +1,58 @@
1
  """
2
- Shared test fixtures and configuration.
3
- Why: Reusable test setup across all test files.
4
  """
5
  import pytest
 
 
 
6
  from fastapi.testclient import TestClient
7
  from backend.main import app
8
 
9
 
10
  @pytest.fixture
11
  def client():
12
- """
13
- TestClient fixture for API testing.
14
- Why: Provides synchronous client for easy testing.
15
- """
16
  return TestClient(app)
17
 
18
 
19
  @pytest.fixture
20
  def sample_image_bytes():
21
  """
22
- Mock image file bytes for testing.
23
- Why: Testing file uploads without real files.
 
 
 
 
 
 
 
 
24
  """
25
- # 1x1 PNG image (smallest valid PNG)
26
- return (
27
- b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01'
28
- b'\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89'
29
- b'\x00\x00\x00\nIDATx\x9cc\x00\x01\x00\x00\x05\x00\x01'
30
- b'\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82'
31
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """
2
+ Shared test fixtures.
3
+ Why: Reusable, realistic test data across all test files.
4
  """
5
  import pytest
6
+ import numpy as np
7
+ from io import BytesIO
8
+ from PIL import Image
9
  from fastapi.testclient import TestClient
10
  from backend.main import app
11
 
12
 
13
  @pytest.fixture
14
  def client():
15
+ """Synchronous test client for API endpoint testing."""
 
 
 
16
  return TestClient(app)
17
 
18
 
19
  @pytest.fixture
20
  def sample_image_bytes():
21
  """
22
+ Generate a realistic 100x100 test image.
23
+
24
+ Why 100x100 with noise?
25
+ - 1x1 pixel → all statistical metrics = 0 (meaningless)
26
+ - Gradient + Gaussian noise → simulates real camera photo
27
+ - Provides meaningful data for:
28
+ * Laplacian variance (noise analysis)
29
+ * FFT (frequency domain)
30
+ * 8x8 block analysis (JPEG artifacts)
31
+ * Color entropy (HSV histogram)
32
  """
33
+ np.random.seed(42) # Deterministic for reproducible tests
34
+
35
+ # Build 100x100 RGB image with gradient base
36
+ img_array = np.zeros((100, 100, 3), dtype=np.uint8)
37
+
38
+ for i in range(100):
39
+ for j in range(100):
40
+ img_array[i, j] = [
41
+ int(i * 2.5), # R: vertical gradient
42
+ int(j * 2.5), # G: horizontal gradient
43
+ int((i + j) * 1.25) # B: diagonal gradient
44
+ ]
45
+
46
+ # Add Gaussian noise (simulates camera sensor noise)
47
+ # Real photos: Noise ~ N(0, σ²), σ ≈ 10-20 for typical cameras
48
+ noise = np.random.normal(0, 15, img_array.shape).astype(np.int16)
49
+ img_array = np.clip(
50
+ img_array.astype(np.int16) + noise, 0, 255
51
+ ).astype(np.uint8)
52
+
53
+ # Encode as PNG bytes
54
+ buffer = BytesIO()
55
+ Image.fromarray(img_array, 'RGB').save(buffer, format='PNG')
56
+ buffer.seek(0)
57
+
58
+ return buffer.read()
backend/tests/test_validators.py CHANGED
@@ -42,6 +42,6 @@ def test_validate_file_complete(sample_image_bytes):
42
  assert result["mime_type"] == "image/png"
43
  assert result["extension"] == "png"
44
  assert result["size_bytes"] > 0
45
- assert result["size_mb"] < 0.01
46
  assert result["filename"] == "test.png"
47
 
 
42
  assert result["mime_type"] == "image/png"
43
  assert result["extension"] == "png"
44
  assert result["size_bytes"] > 0
45
+ assert result["size_mb"] < 1.0
46
  assert result["filename"] == "test.png"
47