anky2002 commited on
Commit
8a218d4
Β·
verified Β·
1 Parent(s): ed5d63e

Upload agents/modality_detector.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. agents/modality_detector.py +183 -263
agents/modality_detector.py CHANGED
@@ -1,300 +1,234 @@
1
  """
2
- FORENSIQ β€” Capture Modality Detector
3
 
4
- Classifies images into capture modalities BEFORE forensic analysis.
5
- Each modality has known false-positive patterns that agents must account for.
6
 
7
- Modalities:
8
- DSLR β€” Traditional camera, raw/JPEG from camera firmware
9
- SMARTPHONE β€” Standard smartphone photo (no portrait mode)
10
- PORTRAIT_MODE β€” Smartphone portrait mode (computational bokeh)
11
- SCREENSHOT β€” Screen capture
12
- MESSAGING β€” Compressed via WhatsApp/Telegram/etc (stripped metadata, double JPEG)
13
- SOCIAL_MEDIA β€” Downloaded from Instagram/Facebook/Twitter (re-encoded, stripped)
14
- UNKNOWN β€” Cannot determine
15
  """
16
 
17
  import numpy as np
18
  from PIL import Image
19
  from scipy.ndimage import gaussian_filter, sobel
 
20
  from dataclasses import dataclass
21
- from typing import Optional
22
 
23
 
24
  @dataclass
25
  class ModalityResult:
26
- modality: str # Primary modality classification
27
- confidence: float # 0-1
28
- indicators: dict # Evidence for the classification
29
- score_adjustments: dict # Per-test score multipliers (1.0 = no change, 0.0 = suppress)
30
 
31
 
32
  def detect_modality(img: Image.Image) -> ModalityResult:
33
- """Detect capture modality from image properties."""
34
  indicators = {}
35
- scores = {} # modality -> evidence strength
36
 
37
  w, h = img.size
38
-
39
- # ── 1. Metadata analysis ──────────────────────────────────────────
40
- try:
41
- exif = img._getexif() or {}
42
- except:
43
- exif = {}
44
-
45
- from PIL.ExifTags import TAGS
46
- decoded = {}
47
- for tid, v in exif.items():
48
- t = TAGS.get(tid, str(tid))
49
- try:
50
- decoded[t] = str(v)[:200]
51
- except:
52
- pass
53
-
54
- has_make = "Make" in decoded
55
- has_model = "Model" in decoded
56
- has_lens = "LensModel" in decoded or "LensInfo" in decoded
57
- has_focal = "FocalLength" in decoded
58
- has_software = "Software" in decoded
59
- has_gps = "GPSInfo" in decoded
60
- info = img.info or {}
61
- source_format = getattr(img, 'format', None)
62
-
63
- cam_fields = sum([has_make, has_model, has_lens, has_focal])
64
- indicators["exif_camera_fields"] = cam_fields
65
- indicators["has_exif"] = bool(decoded)
66
- indicators["format"] = source_format
67
-
68
- # Rich EXIF with lens info β†’ DSLR
69
- if cam_fields >= 3 and has_lens:
70
- scores["DSLR"] = scores.get("DSLR", 0) + 0.4
71
-
72
- # Camera make is a phone brand
73
- phone_brands = ["apple", "samsung", "google", "pixel", "huawei", "xiaomi", "oneplus",
74
- "oppo", "vivo", "realme", "motorola", "lg", "sony xperia", "nothing"]
75
- make = decoded.get("Make", "").lower()
76
- model = decoded.get("Model", "").lower()
77
- if any(b in make or b in model for b in phone_brands):
78
- scores["SMARTPHONE"] = scores.get("SMARTPHONE", 0) + 0.5
79
- indicators["phone_brand"] = True
80
-
81
- # No EXIF at all β†’ messaging/social or AI
82
- if not decoded:
83
- scores["MESSAGING"] = scores.get("MESSAGING", 0) + 0.3
84
- scores["SOCIAL_MEDIA"] = scores.get("SOCIAL_MEDIA", 0) + 0.2
85
- indicators["no_exif"] = True
86
-
87
- # ── 2. Resolution analysis ────────────────────────────────────────
88
- mp = w * h / 1e6
89
- indicators["megapixels"] = round(mp, 2)
90
-
91
- # Common messaging app resolutions (WhatsApp compresses to ~1600px max side)
92
- max_side = max(w, h)
93
- if max_side <= 1600 and mp < 3:
94
- scores["MESSAGING"] = scores.get("MESSAGING", 0) + 0.25
95
- indicators["low_res"] = True
96
-
97
- # Screenshot-like aspect ratios (phone screens)
98
- ratio = max(w, h) / min(w, h)
99
- if ratio > 1.9 and max_side > 1000: # Tall phone screenshots
100
- scores["SCREENSHOT"] = scores.get("SCREENSHOT", 0) + 0.3
101
- indicators["tall_ratio"] = round(ratio, 2)
102
-
103
- # Standard phone ratios: 4:3 or 16:9
104
- if abs(ratio - 4/3) < 0.05 or abs(ratio - 16/9) < 0.05:
105
- scores["SMARTPHONE"] = scores.get("SMARTPHONE", 0) + 0.1
106
-
107
- # ── 3. Portrait mode detection (computational bokeh) ──────────────
108
  gray = np.array(img.convert("L")).astype(np.float64)
109
 
110
- # Compute local sharpness map
 
 
 
111
  lap = np.array([[0, 1, 0], [1, -4, 1], [0, 1, 0]], dtype=np.float64)
112
- from scipy.signal import convolve2d
113
  laplacian = convolve2d(gray, lap, mode="same", boundary="symm")
114
- sharpness = gaussian_filter(np.abs(laplacian), sigma=10)
 
 
 
 
 
115
 
116
- # Portrait mode signature: sharp foreground + uniformly blurred background
117
- # with an ABRUPT transition between them (not gradual like real DoF)
118
- sharp_thresh = np.percentile(sharpness, 75)
119
- blur_thresh = np.percentile(sharpness, 25)
120
 
 
 
 
121
  sharp_region = sharpness > sharp_thresh
122
  blur_region = sharpness < blur_thresh
 
 
123
 
124
- sharp_fraction = float(np.mean(sharp_region))
125
- blur_fraction = float(np.mean(blur_region))
126
-
127
- # CRITICAL: Check absolute sharpness level, not just relative
128
- # Real portrait photos have genuinely sharp foreground (textures, edges, pores)
129
- # AI images are smooth everywhere β€” even the "sharp" region is soft
130
- peak_sharpness = float(np.percentile(sharpness, 95))
131
- median_sharpness = float(np.median(sharpness))
132
-
133
- # Check if blur is very uniform (computational vs optical)
134
- blur_values = sharpness[blur_region] if np.any(blur_region) else np.array([0])
135
- blur_uniformity = 1.0 - min(float(np.std(blur_values)) / (float(np.mean(blur_values)) + 1e-9), 1.0)
136
-
137
- # Check transition abruptness
138
- sharpness_grad = np.hypot(
139
- sobel(sharpness, axis=0),
140
- sobel(sharpness, axis=1)
141
- )
142
- max_transition = float(np.percentile(sharpness_grad, 99))
143
- mean_transition = float(np.mean(sharpness_grad))
144
- transition_abruptness = max_transition / (mean_transition + 1e-9)
145
-
146
- indicators["sharp_fraction"] = round(sharp_fraction, 3)
147
- indicators["blur_fraction"] = round(blur_fraction, 3)
148
- indicators["blur_uniformity"] = round(blur_uniformity, 3)
149
- indicators["transition_abruptness"] = round(transition_abruptness, 3)
150
- indicators["peak_sharpness"] = round(peak_sharpness, 2)
151
- indicators["median_sharpness"] = round(median_sharpness, 2)
152
-
153
- # Portrait mode detection β€” requires BOTH relative and absolute sharpness
154
- # A real portrait photo has genuinely sharp foreground detail
155
- # An AI-generated smooth image has low peak sharpness even if center > edges
156
- has_genuine_detail = peak_sharpness > 10.0 # Real photos have Laplacian variance > 10
157
-
158
- portrait_signals = 0
159
- if blur_fraction > 0.2 and sharp_fraction > 0.1 and has_genuine_detail:
160
- portrait_signals += 1
161
- if blur_uniformity > 0.5 and has_genuine_detail:
162
- portrait_signals += 1
163
- if transition_abruptness > 5 and has_genuine_detail:
164
- portrait_signals += 1
165
-
166
- # Strong portrait mode: at least 2 of 3 signals AND genuine foreground detail
167
- if portrait_signals >= 2 and has_genuine_detail:
168
- scores["PORTRAIT_MODE"] = scores.get("PORTRAIT_MODE", 0) + 0.3 * portrait_signals
169
- indicators["portrait_mode_signature"] = True
170
- elif portrait_signals == 1 and blur_fraction > 0.25 and has_genuine_detail:
171
- scores["PORTRAIT_MODE"] = scores.get("PORTRAIT_MODE", 0) + 0.2
172
- indicators["portrait_mode_weak"] = True
173
-
174
- if not has_genuine_detail:
175
- indicators["low_detail_image"] = True # Smooth everywhere = possible AI
176
-
177
- # ── 4. Screenshot detection ───────────────────────────────────────
178
- # Screenshots have: perfect pixel edges, UI elements, uniform background areas
179
- edge_mag = np.hypot(sobel(gray, 0), sobel(gray, 1))
180
 
181
- # Perfect horizontal/vertical edges (UI elements)
182
- strong_edges = edge_mag > np.percentile(edge_mag, 95)
183
- gx = sobel(gray, axis=1)
184
- gy = sobel(gray, axis=0)
 
185
 
186
- # Ratio of H/V edges to diagonal edges
187
- h_edges = np.abs(gx) > np.abs(gy) * 3 # Strongly horizontal
188
- v_edges = np.abs(gy) > np.abs(gx) * 3 # Strongly vertical
189
- hv_ratio = float(np.sum(h_edges | v_edges)) / (float(np.sum(strong_edges)) + 1e-9)
190
 
191
- if hv_ratio > 0.6:
192
- scores["SCREENSHOT"] = scores.get("SCREENSHOT", 0) + 0.3
193
- indicators["hv_edge_ratio"] = round(hv_ratio, 3)
194
-
195
- # ── 5. Double JPEG / messaging detection ──────────────────────────
196
- # Check for 8x8 block boundary artifacts (double JPEG)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
197
  hc, wc = (gray.shape[0] // 8) * 8, (gray.shape[1] // 8) * 8
 
198
  if hc > 16 and wc > 16:
199
  g = gray[:hc, :wc]
200
  bd = [float(np.mean(np.abs(g[i, :] - g[i-1, :]))) for i in range(8, hc, 8)]
201
  it = [float(np.mean(np.abs(g[i, :] - g[i-1, :]))) for i in range(1, hc) if i % 8 != 0]
202
  if bd and it:
203
  blockiness = float(np.mean(bd)) / (float(np.mean(it)) + 1e-9)
204
- if blockiness > 1.3:
205
- scores["MESSAGING"] = scores.get("MESSAGING", 0) + 0.2
206
- indicators["double_jpeg"] = round(blockiness, 3)
207
 
208
- # ── 6. Determine primary modality ─────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
209
  if not scores:
210
  modality = "UNKNOWN"
211
- confidence = 0.2
212
  else:
213
  modality = max(scores, key=scores.get)
214
- confidence = min(1.0, scores[modality])
215
 
216
- # Override: portrait mode wins over messaging/smartphone when detected
217
- if scores.get("PORTRAIT_MODE", 0) > 0.2:
218
  modality = "PORTRAIT_MODE"
219
- confidence = min(1.0, scores["PORTRAIT_MODE"])
220
 
221
- # SAFETY GUARD: If image has no genuine photographic detail (low peak sharpness),
222
- # it's likely AI-generated, not a real camera photo. In this case, do NOT apply
223
- # any modality suppression β€” let all forensic tests fire at full strength.
224
- if indicators.get("low_detail_image", False):
225
  modality = "UNKNOWN"
226
- confidence = 0.2
227
- indicators["modality_override"] = "Low-detail image β€” suppression disabled to avoid protecting AI content"
 
 
228
 
229
- # ── 7. Build score adjustments ────────────────────────────────────
230
- # Merge adjustments when multiple modalities detected
231
- # (e.g., portrait mode photo sent via messaging app gets BOTH sets)
232
  adjustments = _get_modality_adjustments(modality)
233
 
234
- # If messaging signals are present alongside portrait mode, merge messaging adjustments
235
- if modality == "PORTRAIT_MODE" and scores.get("MESSAGING", 0) > 0.2:
236
- messaging_adj = _get_modality_adjustments("MESSAGING")
237
- for test_name, multiplier in messaging_adj.items():
238
- if test_name not in adjustments:
239
- adjustments[test_name] = multiplier
240
- else:
241
- # Take the more suppressive (lower) multiplier
242
- adjustments[test_name] = min(adjustments[test_name], multiplier)
243
  indicators["dual_modality"] = "PORTRAIT_MODE + MESSAGING"
244
 
245
- return ModalityResult(
246
- modality=modality,
247
- confidence=round(confidence, 3),
248
- indicators=indicators,
249
- score_adjustments=adjustments,
250
- )
251
 
252
 
253
  def _get_modality_adjustments(modality: str) -> dict:
254
- """
255
- Return per-test score multipliers for known false-positive patterns.
256
- 1.0 = no change, 0.0 = suppress entirely, 0.5 = halve the score.
257
- """
258
-
259
  if modality == "PORTRAIT_MODE":
260
  return {
261
- # These tests false-positive on computational bokeh
262
- "Autocorrelation Peak": 0.1, # Bokeh creates periodic patterns
263
- "Texture Repetition": 0.1, # Bokeh is repetitive by design
264
- "VAE Patch Boundaries": 0.2, # Segmentation mask operates in blocks
265
- "PRNU Uniformity": 0.15, # Dual-region noise (sharp vs blur)
266
- "Poisson-Gaussian Model": 0.3, # Noise model breaks with synthetic blur
267
- "DoF Consistency": 0.2, # Abrupt transitions are EXPECTED
268
- "Vignetting cos⁴θ": 0.3, # Smartphones don't follow cos⁴θ
269
- "HF Noise Structure": 0.3, # Blur region has different noise
270
- "Noise Spatial Frequency": 0.3, # Same reason
271
- "CFA Nyquist": 0.25, # Computational processing destroys CFA traces entirely
272
- # Spectral tests affected by computational photography
273
- "Spectral Slope 1/fΒ²": 0.5, # Frequency-selective sharpening steepens slope
274
- "Spectral Symmetry": 0.4, # Depth-based processing creates asymmetry
275
- "Phase Coherence": 0.4, # Segmentation boundary = phase discontinuity
276
- # Sensor tests affected by smartphone tone curves
277
- "Pixel Response Linearity": 0.3, # Aggressive HDR/tone mapping = expected non-linearity
278
- # Sensor tests affected by computational processing
279
- "Demosaic Interpolation": 0.4, # Heavy ISP processing removes demosaic traces
280
- "Saturation Clipping": 0.4, # Computational HDR avoids clipping artificially
281
  }
282
-
283
  elif modality == "MESSAGING":
284
  return {
285
- # These tests false-positive on messaging compression
286
- "EXIF Completeness": 0.15, # WhatsApp strips ALL EXIF β€” this is normal
287
- "Compression Ghosts": 0.2, # Double JPEG is expected
288
- "ICC Color Profile": 0.2, # Stripped by messaging apps
289
- "Maker Note": 0.2, # Stripped
290
- "Thumbnail Check": 0.2, # Stripped
291
- "Software Detection": 0.2, # Stripped
292
- "JPEG Quantization": 0.3, # Re-encoded with generic tables
293
- "CFA Nyquist": 0.5, # Re-encoding destroys CFA traces
294
- "Watermark Detection": 0.2, # JPEG grid creates spectral peaks β€” not a real watermark
295
- "Demosaic Interpolation": 0.5, # Re-encoding smooths demosaic
296
  }
297
-
298
  elif modality == "SOCIAL_MEDIA":
299
  return {
300
  "EXIF Completeness": 0.2,
@@ -303,36 +237,22 @@ def _get_modality_adjustments(modality: str) -> dict:
303
  "Maker Note": 0.2,
304
  "Thumbnail Check": 0.3,
305
  }
306
-
307
  elif modality == "SCREENSHOT":
308
  return {
309
- # Screenshots are NOT photos β€” most optical/sensor tests are meaningless
310
- "Vignetting cos⁴θ": 0.1,
311
- "Vignetting Symmetry": 0.1,
312
- "Lens Distortion": 0.1,
313
- "Field Curvature": 0.1,
314
- "CA Magnitude": 0.1,
315
- "CA Radial Gradient": 0.1,
316
- "Lateral CA": 0.1,
317
- "Purple Fringing": 0.1,
318
- "Bokeh Shape": 0.1,
319
- "PRNU Uniformity": 0.1,
320
- "Bayer CFA Pattern": 0.1,
321
- "CFA Nyquist": 0.1,
322
- "Hot/Dead Pixels": 0.1,
323
- "Noise Autocorrelation": 0.1,
324
- "Demosaic Interpolation": 0.1,
325
  }
326
-
327
  elif modality == "SMARTPHONE":
328
  return {
329
- # Smartphones use computational photography β€” mild suppression
330
- "Vignetting cos⁴θ": 0.5, # Computational correction
331
- "CFA Nyquist": 0.7, # Heavy ISP processing
332
- "Poisson-Gaussian Model": 0.7, # Noise reduction
333
- "Pixel Response Linearity": 0.4, # HDR/tone mapping = non-linear
334
- "Spectral Slope 1/fΒ²": 0.7, # Sharpening affects slope
335
  }
336
-
337
- else: # DSLR or UNKNOWN
338
- return {} # No adjustments β€” full scoring
 
1
  """
2
+ FORENSIQ β€” Capture Modality Detector v2
3
 
4
+ Classifies images BEFORE forensic analysis. Pure content-based detection
5
+ that works even when Gradio strips metadata (format=None, no EXIF).
6
 
7
+ Key fix: detection works entirely from image pixel analysis, not metadata.
8
+ Metadata signals are bonus evidence only.
 
 
 
 
 
 
9
  """
10
 
11
  import numpy as np
12
  from PIL import Image
13
  from scipy.ndimage import gaussian_filter, sobel
14
+ from scipy.signal import convolve2d
15
  from dataclasses import dataclass
 
16
 
17
 
18
  @dataclass
19
  class ModalityResult:
20
+ modality: str
21
+ confidence: float
22
+ indicators: dict
23
+ score_adjustments: dict
24
 
25
 
26
  def detect_modality(img: Image.Image) -> ModalityResult:
27
+ """Detect capture modality from image content and metadata."""
28
  indicators = {}
29
+ scores = {}
30
 
31
  w, h = img.size
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  gray = np.array(img.convert("L")).astype(np.float64)
33
 
34
+ # ═══ CONTENT-BASED DETECTION (works without metadata) ═════════════
35
+
36
+ # ── Portrait mode detection ─────────────���─────────────────────────
37
+ # Core signal: bimodal sharpness distribution (sharp fg + uniform blur bg)
38
  lap = np.array([[0, 1, 0], [1, -4, 1], [0, 1, 0]], dtype=np.float64)
 
39
  laplacian = convolve2d(gray, lap, mode="same", boundary="symm")
40
+ sharpness = gaussian_filter(np.abs(laplacian), sigma=max(10, min(h, w) // 80))
41
+
42
+ p25 = float(np.percentile(sharpness, 25))
43
+ p50 = float(np.percentile(sharpness, 50))
44
+ p75 = float(np.percentile(sharpness, 75))
45
+ p95 = float(np.percentile(sharpness, 95))
46
 
47
+ # Bimodality: large gap between p25 and p75
48
+ iqr = p75 - p25
49
+ bimodal_ratio = iqr / (p50 + 1e-9)
 
50
 
51
+ # Sharp region detection
52
+ sharp_thresh = p75
53
+ blur_thresh = p25
54
  sharp_region = sharpness > sharp_thresh
55
  blur_region = sharpness < blur_thresh
56
+ sharp_frac = float(np.mean(sharp_region))
57
+ blur_frac = float(np.mean(blur_region))
58
 
59
+ # Blur uniformity (computational blur is very uniform)
60
+ blur_vals = sharpness[blur_region] if np.any(blur_region) else np.array([1])
61
+ blur_uniformity = 1.0 - min(float(np.std(blur_vals)) / (float(np.mean(blur_vals)) + 1e-9), 1.0)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
 
63
+ # Transition abruptness (computational segmentation = sharp boundary)
64
+ sharpness_grad = np.hypot(sobel(sharpness, 0), sobel(sharpness, 1))
65
+ max_grad = float(np.percentile(sharpness_grad, 99))
66
+ mean_grad = float(np.mean(sharpness_grad))
67
+ transition = max_grad / (mean_grad + 1e-9)
68
 
69
+ # Absolute detail level (real photos have p95 > 5 even after heavy compression)
70
+ has_detail = p95 > 5.0
 
 
71
 
72
+ indicators["p95_sharpness"] = round(p95, 2)
73
+ indicators["bimodal_ratio"] = round(bimodal_ratio, 3)
74
+ indicators["blur_uniformity"] = round(blur_uniformity, 3)
75
+ indicators["transition_abruptness"] = round(transition, 2)
76
+ indicators["has_detail"] = has_detail
77
+
78
+ # Portrait mode scoring β€” multiple independent signals
79
+ portrait_score = 0.0
80
+ if has_detail and bimodal_ratio > 1.0:
81
+ portrait_score += 0.25 # Strong bimodal sharpness
82
+ if has_detail and blur_uniformity > 0.5:
83
+ portrait_score += 0.2 # Uniform blur region
84
+ if has_detail and transition > 4.0:
85
+ portrait_score += 0.2 # Abrupt sharp/blur boundary
86
+ if has_detail and blur_frac > 0.2 and sharp_frac > 0.1:
87
+ portrait_score += 0.15 # Distinct regions exist
88
+
89
+ if portrait_score > 0.3:
90
+ scores["PORTRAIT_MODE"] = portrait_score
91
+ indicators["portrait_detected"] = True
92
+
93
+ # ── Screenshot detection ──────────────────────────────────────────
94
+ edge_mag = np.hypot(sobel(gray, 0), sobel(gray, 1))
95
+ strong = edge_mag > np.percentile(edge_mag, 95)
96
+ gx = sobel(gray, axis=1); gy = sobel(gray, axis=0)
97
+ h_edges = np.abs(gx) > np.abs(gy) * 3
98
+ v_edges = np.abs(gy) > np.abs(gx) * 3
99
+ hv_ratio = float(np.sum(h_edges | v_edges)) / (float(np.sum(strong)) + 1e-9)
100
+
101
+ ratio = max(w, h) / (min(w, h) + 1e-9)
102
+ if hv_ratio > 0.6 and ratio > 1.8:
103
+ scores["SCREENSHOT"] = 0.6
104
+ indicators["screenshot_detected"] = True
105
+
106
+ # ── Double JPEG detection (messaging) ─────────────────────────────
107
  hc, wc = (gray.shape[0] // 8) * 8, (gray.shape[1] // 8) * 8
108
+ blockiness = 1.0
109
  if hc > 16 and wc > 16:
110
  g = gray[:hc, :wc]
111
  bd = [float(np.mean(np.abs(g[i, :] - g[i-1, :]))) for i in range(8, hc, 8)]
112
  it = [float(np.mean(np.abs(g[i, :] - g[i-1, :]))) for i in range(1, hc) if i % 8 != 0]
113
  if bd and it:
114
  blockiness = float(np.mean(bd)) / (float(np.mean(it)) + 1e-9)
 
 
 
115
 
116
+ indicators["blockiness"] = round(blockiness, 3)
117
+
118
+ # ═══ METADATA-BASED DETECTION (bonus signals) ═════════════════════
119
+
120
+ try:
121
+ exif = img._getexif() or {}
122
+ except:
123
+ exif = {}
124
+
125
+ from PIL.ExifTags import TAGS
126
+ decoded = {}
127
+ for tid, v in exif.items():
128
+ t = TAGS.get(tid, str(tid))
129
+ try: decoded[t] = str(v)[:200]
130
+ except: pass
131
+
132
+ has_exif = bool(decoded)
133
+ indicators["has_exif"] = has_exif
134
+ indicators["format"] = getattr(img, 'format', None)
135
+
136
+ # Phone brand in EXIF
137
+ phone_brands = ["apple", "samsung", "google", "pixel", "huawei", "xiaomi", "oneplus",
138
+ "oppo", "vivo", "realme", "motorola", "lg", "nothing"]
139
+ make = decoded.get("Make", "").lower()
140
+ model = decoded.get("Model", "").lower()
141
+ is_phone = any(b in make or b in model for b in phone_brands)
142
+
143
+ if is_phone:
144
+ scores["SMARTPHONE"] = scores.get("SMARTPHONE", 0) + 0.4
145
+ indicators["phone_brand"] = True
146
+
147
+ # Rich EXIF with lens β†’ DSLR
148
+ cam_fields = sum(["Make" in decoded, "Model" in decoded,
149
+ "LensModel" in decoded or "LensInfo" in decoded, "FocalLength" in decoded])
150
+ if cam_fields >= 3 and ("LensModel" in decoded or "LensInfo" in decoded):
151
+ scores["DSLR"] = scores.get("DSLR", 0) + 0.5
152
+
153
+ # No EXIF + low res + double JPEG β†’ messaging
154
+ max_side = max(w, h)
155
+ no_exif_low_res = not has_exif and max_side <= 1600
156
+ if no_exif_low_res:
157
+ scores["MESSAGING"] = scores.get("MESSAGING", 0) + 0.3
158
+ indicators["no_exif_low_res"] = True
159
+ if blockiness > 1.3:
160
+ scores["MESSAGING"] = scores.get("MESSAGING", 0) + 0.2
161
+ indicators["double_jpeg"] = True
162
+
163
+ # ═══ DETERMINE MODALITY ═══════════════════════════════════════��═══
164
+
165
  if not scores:
166
  modality = "UNKNOWN"
167
+ conf = 0.2
168
  else:
169
  modality = max(scores, key=scores.get)
170
+ conf = min(1.0, scores[modality])
171
 
172
+ # Portrait mode always wins when detected (it's the most specific modality)
173
+ if scores.get("PORTRAIT_MODE", 0) > 0.3:
174
  modality = "PORTRAIT_MODE"
175
+ conf = min(1.0, scores["PORTRAIT_MODE"])
176
 
177
+ # SAFETY GUARD: No detail = possible AI. Disable all suppression.
178
+ if not has_detail:
 
 
179
  modality = "UNKNOWN"
180
+ conf = 0.2
181
+ indicators["safety_override"] = "Low-detail image β€” suppression disabled"
182
+
183
+ # ═══ BUILD ADJUSTMENTS ════════════════════════════════════════════
184
 
 
 
 
185
  adjustments = _get_modality_adjustments(modality)
186
 
187
+ # Merge messaging adjustments when portrait + messaging both detected
188
+ if modality == "PORTRAIT_MODE" and scores.get("MESSAGING", 0) > 0.15:
189
+ msg_adj = _get_modality_adjustments("MESSAGING")
190
+ for k, v in msg_adj.items():
191
+ adjustments[k] = min(adjustments.get(k, 1.0), v)
 
 
 
 
192
  indicators["dual_modality"] = "PORTRAIT_MODE + MESSAGING"
193
 
194
+ indicators["modality_scores"] = {k: round(v, 3) for k, v in scores.items()}
195
+
196
+ return ModalityResult(modality, round(conf, 3), indicators, adjustments)
 
 
 
197
 
198
 
199
  def _get_modality_adjustments(modality: str) -> dict:
 
 
 
 
 
200
  if modality == "PORTRAIT_MODE":
201
  return {
202
+ "Autocorrelation Peak": 0.1,
203
+ "Texture Repetition": 0.1,
204
+ "VAE Patch Boundaries": 0.2,
205
+ "PRNU Uniformity": 0.15,
206
+ "Poisson-Gaussian Model": 0.3,
207
+ "DoF Consistency": 0.2,
208
+ "Vignetting cos⁴θ": 0.3,
209
+ "HF Noise Structure": 0.3,
210
+ "Noise Spatial Frequency": 0.3,
211
+ "CFA Nyquist": 0.25,
212
+ "Spectral Slope 1/fΒ²": 0.5,
213
+ "Spectral Symmetry": 0.4,
214
+ "Phase Coherence": 0.4,
215
+ "Pixel Response Linearity": 0.3,
216
+ "Demosaic Interpolation": 0.4,
217
+ "Saturation Clipping": 0.4,
 
 
 
 
218
  }
 
219
  elif modality == "MESSAGING":
220
  return {
221
+ "EXIF Completeness": 0.15,
222
+ "Compression Ghosts": 0.2,
223
+ "ICC Color Profile": 0.2,
224
+ "Maker Note": 0.2,
225
+ "Thumbnail Check": 0.2,
226
+ "Software Detection": 0.2,
227
+ "JPEG Quantization": 0.3,
228
+ "CFA Nyquist": 0.5,
229
+ "Watermark Detection": 0.2,
230
+ "Demosaic Interpolation": 0.5,
 
231
  }
 
232
  elif modality == "SOCIAL_MEDIA":
233
  return {
234
  "EXIF Completeness": 0.2,
 
237
  "Maker Note": 0.2,
238
  "Thumbnail Check": 0.3,
239
  }
 
240
  elif modality == "SCREENSHOT":
241
  return {
242
+ "Vignetting cos⁴θ": 0.1, "Vignetting Symmetry": 0.1,
243
+ "Lens Distortion": 0.1, "Field Curvature": 0.1,
244
+ "CA Magnitude": 0.1, "CA Radial Gradient": 0.1, "Lateral CA": 0.1,
245
+ "Purple Fringing": 0.1, "Bokeh Shape": 0.1,
246
+ "PRNU Uniformity": 0.1, "Bayer CFA Pattern": 0.1, "CFA Nyquist": 0.1,
247
+ "Hot/Dead Pixels": 0.1, "Noise Autocorrelation": 0.1, "Demosaic Interpolation": 0.1,
 
 
 
 
 
 
 
 
 
 
248
  }
 
249
  elif modality == "SMARTPHONE":
250
  return {
251
+ "Vignetting cos⁴θ": 0.5,
252
+ "CFA Nyquist": 0.7,
253
+ "Poisson-Gaussian Model": 0.7,
254
+ "Pixel Response Linearity": 0.4,
255
+ "Spectral Slope 1/fΒ²": 0.7,
 
256
  }
257
+ else:
258
+ return {}