anky2002 commited on
Commit
7f80464
Β·
verified Β·
1 Parent(s): c1a279a

feat: overhaul screenshot detection + suppression + explanation text (v7)

Browse files

Detection improvements:
- Standard screen resolution matching (19 common resolutions including Retina)
- Low unique color count (<32 per channel β€” real UI renders use exact colors)
- H/V edge dominance with minimum edge strength gate
- Hard Bayer CFA exclusion (cameras can't be screenshots)
- 2-trait gate requiring at least 1 content-based trait (prevents false triggers)
- Safety guard exception: screenshots allowed through low-detail guard at score>=0.6

Suppression expansion (15 β†’ 37 tests):
- All optical lens tests (no lens on screenshots)
- All sensor tests (no camera sensor)
- Diffusion Notches Γ—0.15 (LCD pixel grid creates false spectral harmonics)
- Benford's Law Γ—0.2, Color Histogram Γ—0.2 (UI color distributions violate natural priors)
- DCT/Wavelet Kurtosis Γ—0.3, Gradient Sparsity Γ—0.3
- EXIF/Maker Note/Thumbnail/GPS Γ—0.1 (screenshots have no camera metadata)

Explanation text:
- Screenshot-specific disclosure in forensic report explaining methodology limitations
- Screenshot-aware conclusion/recommendation in court brief
- Explicit note that photographic forensic tests don't apply to UI screenshots

Files changed (1) hide show
  1. agents/modality_detector.py +100 -15
agents/modality_detector.py CHANGED
@@ -1,15 +1,18 @@
1
  """
2
- FORENSIQ β€” Capture Modality Detector v6
 
 
 
 
 
 
 
3
 
4
  v6: Added Vignetting cos⁴θ, Bokeh Shape, and Fixed Pattern Noise Γ—0.3 suppression
5
- for MACRO_DSLR modality. Macro lenses have non-standard field illumination (not cos⁴θ),
6
- extremely smooth circular bokeh from long focal length, and low row/column variance
7
- in the smooth background region.
8
 
9
  v5: Added minimum bayer_margin threshold (0.005) to prevent JPEG re-encoding
10
- from creating false Bayer CFA patterns on AI images. Real cameras have
11
- bayer_margin > 0.01; AI images through JPEG have margin < 0.001.
12
- Also: sharpness_ratio > 3.0 hard gate for macro detection.
13
  """
14
 
15
  import sys
@@ -44,8 +47,6 @@ def detect_modality(img: Image.Image) -> ModalityResult:
44
  rgb = np.array(img.convert("RGB")).astype(np.float64)
45
 
46
  # ═══ CRITICAL PRE-CHECK: Bayer CFA pattern ═══════════════════════
47
- # Real camera: Οƒ_green < Οƒ_red β‰ˆ Οƒ_blue (green has 2x sampling density)
48
- # AI image through JPEG: all channels have ~same noise (margin β‰ˆ 0)
49
  noise_std = {}
50
  for c, nm in enumerate(["red", "green", "blue"]):
51
  ch = rgb[:, :, c]
@@ -53,7 +54,6 @@ def detect_modality(img: Image.Image) -> ModalityResult:
53
  noise_std[nm] = float(np.std(ch - dn))
54
 
55
  bayer_margin = min(noise_std["red"], noise_std["blue"]) - noise_std["green"]
56
- # Require BOTH: green < min(red, blue) AND margin above noise floor
57
  has_bayer = (noise_std["green"] < min(noise_std["red"], noise_std["blue"])
58
  and bayer_margin > MIN_BAYER_MARGIN)
59
 
@@ -83,7 +83,6 @@ def detect_modality(img: Image.Image) -> ModalityResult:
83
  sharp_region = sharpness > sharp_thresh
84
  sharp_frac = float(np.mean(sharp_region))
85
 
86
- # Blur region: content-based threshold (20% of p95)
87
  blur_content_thresh = p95 * 0.20
88
  blur_region = sharpness < blur_content_thresh
89
  blur_frac = float(np.mean(blur_region))
@@ -177,6 +176,11 @@ def detect_modality(img: Image.Image) -> ModalityResult:
177
  indicators["macro_detected"] = True
178
 
179
  # ── Screenshot detection ──────────────────────────────────────────
 
 
 
 
 
180
  edge_mag = np.hypot(sobel(gray, 0), sobel(gray, 1))
181
  strong = edge_mag > np.percentile(edge_mag, 95)
182
  gx = sobel(gray, axis=1); gy = sobel(gray, axis=0)
@@ -184,11 +188,70 @@ def detect_modality(img: Image.Image) -> ModalityResult:
184
  v_edges = np.abs(gy) > np.abs(gx) * 3
185
  hv_ratio = float(np.sum(h_edges | v_edges)) / (float(np.sum(strong)) + 1e-9)
186
 
187
- ratio = max(w, h) / (min(w, h) + 1e-9)
188
- if hv_ratio > 0.6 and ratio > 1.8:
189
- scores["SCREENSHOT"] = 0.6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
190
  indicators["screenshot_detected"] = True
191
 
 
 
 
 
192
  # ── Double JPEG detection (messaging) ─────────────────────────────
193
  hc, wc = (gray.shape[0] // 8) * 8, (gray.shape[1] // 8) * 8
194
  blockiness = 1.0
@@ -260,10 +323,17 @@ def detect_modality(img: Image.Image) -> ModalityResult:
260
  conf = min(1.0, scores["PORTRAIT_MODE"])
261
 
262
  # SAFETY GUARD 1: No detail = possible AI
263
- if not has_detail:
 
 
264
  modality = "UNKNOWN"
265
  conf = 0.2
266
  indicators["safety_override"] = "Low-detail image β€” suppression disabled"
 
 
 
 
 
267
 
268
  # SAFETY GUARD 2: No real Bayer = not a real camera
269
  if not has_bayer and modality in ("PORTRAIT_MODE", "SMARTPHONE", "MESSAGING"):
@@ -338,12 +408,27 @@ def _get_modality_adjustments(modality: str) -> dict:
338
  }
339
  elif modality == "SCREENSHOT":
340
  return {
 
341
  "Vignetting cos⁴θ": 0.1, "Vignetting Symmetry": 0.1,
342
  "Lens Distortion": 0.1, "Field Curvature": 0.1,
343
  "CA Magnitude": 0.1, "CA Radial Gradient": 0.1, "Lateral CA": 0.1,
344
  "Purple Fringing": 0.1, "Bokeh Shape": 0.1,
 
 
 
345
  "PRNU Uniformity": 0.1, "Bayer CFA Pattern": 0.1, "CFA Nyquist": 0.1,
346
  "Hot/Dead Pixels": 0.1, "Noise Autocorrelation": 0.1, "Demosaic Interpolation": 0.1,
 
 
 
 
 
 
 
 
 
 
 
347
  }
348
  elif modality == "SMARTPHONE":
349
  return {
 
1
  """
2
+ FORENSIQ β€” Capture Modality Detector v7
3
+
4
+ v7: Overhauled SCREENSHOT detection β€” now detects OS screenshots (pixel-perfect,
5
+ standard resolution, few unique colors, no Bayer CFA, uniform sharpness) in
6
+ addition to photographed screens (H/V edge dominance). Expanded SCREENSHOT
7
+ suppression from 15 to 30+ tests covering lens physics, sensor characteristics,
8
+ LCD pixel grid frequency artifacts (Diffusion Notches), and UI color distribution
9
+ violations (Benford's Law, Color Histogram).
10
 
11
  v6: Added Vignetting cos⁴θ, Bokeh Shape, and Fixed Pattern Noise Γ—0.3 suppression
12
+ for MACRO_DSLR modality.
 
 
13
 
14
  v5: Added minimum bayer_margin threshold (0.005) to prevent JPEG re-encoding
15
+ from creating false Bayer CFA patterns on AI images.
 
 
16
  """
17
 
18
  import sys
 
47
  rgb = np.array(img.convert("RGB")).astype(np.float64)
48
 
49
  # ═══ CRITICAL PRE-CHECK: Bayer CFA pattern ═══════════════════════
 
 
50
  noise_std = {}
51
  for c, nm in enumerate(["red", "green", "blue"]):
52
  ch = rgb[:, :, c]
 
54
  noise_std[nm] = float(np.std(ch - dn))
55
 
56
  bayer_margin = min(noise_std["red"], noise_std["blue"]) - noise_std["green"]
 
57
  has_bayer = (noise_std["green"] < min(noise_std["red"], noise_std["blue"])
58
  and bayer_margin > MIN_BAYER_MARGIN)
59
 
 
83
  sharp_region = sharpness > sharp_thresh
84
  sharp_frac = float(np.mean(sharp_region))
85
 
 
86
  blur_content_thresh = p95 * 0.20
87
  blur_region = sharpness < blur_content_thresh
88
  blur_frac = float(np.mean(blur_region))
 
176
  indicators["macro_detected"] = True
177
 
178
  # ── Screenshot detection ──────────────────────────────────────────
179
+ # Two types: (1) OS screenshot (PrintScreen/cmd+Shift) β€” pixel-perfect,
180
+ # no lens physics, exact resolution match, few unique colors
181
+ # (2) Photographed screen β€” has lens physics but also display grid artifacts
182
+
183
+ # H/V edge dominance (UI elements are rectilinear)
184
  edge_mag = np.hypot(sobel(gray, 0), sobel(gray, 1))
185
  strong = edge_mag > np.percentile(edge_mag, 95)
186
  gx = sobel(gray, axis=1); gy = sobel(gray, axis=0)
 
188
  v_edges = np.abs(gy) > np.abs(gx) * 3
189
  hv_ratio = float(np.sum(h_edges | v_edges)) / (float(np.sum(strong)) + 1e-9)
190
 
191
+ # H/V ratio is only meaningful if edges are genuinely strong (not noise)
192
+ median_edge = float(np.median(edge_mag))
193
+ strong_edges_present = float(np.percentile(edge_mag, 95)) > max(5.0, median_edge * 3)
194
+
195
+ aspect = max(w, h) / (min(w, h) + 1e-9)
196
+
197
+ # Standard screen resolutions (and 2Γ— Retina variants)
198
+ standard_res = {
199
+ (1920, 1080), (2560, 1440), (3840, 2160), (1366, 768), (1280, 720),
200
+ (1440, 900), (1680, 1050), (2560, 1600), (2880, 1800), (3024, 1964),
201
+ (1536, 864), (1600, 900), (3456, 2234), (2560, 1080), (3440, 1440),
202
+ (1280, 800), (1024, 768), (2048, 1536), (2732, 2048),
203
+ }
204
+ is_standard_res = (w, h) in standard_res or (h, w) in standard_res
205
+
206
+ # Count unique colors per channel β€” UI screenshots use few exact colors
207
+ unique_colors = [len(np.unique(rgb[:, :, c].astype(np.uint8))) for c in range(3)]
208
+ max_unique = max(unique_colors)
209
+ low_color_count = max_unique < 32 # Pure UI screenshots use <30 distinct values per channel
210
+
211
+ # Uniform sharpness β€” no blur gradient (unlike camera photos)
212
+ sharpness_cv = float(np.std(sharpness)) / (float(np.mean(sharpness)) + 1e-9)
213
+ uniform_sharpness = sharpness_cv < 0.5
214
+
215
+ # No Bayer CFA = not a camera sensor
216
+ no_bayer = not has_bayer
217
+
218
+ screenshot_score = 0.0
219
+ screenshot_traits = 0 # Count distinct UI traits for gating
220
+
221
+ # Hard gate: images with real Bayer CFA pattern are from cameras, not screenshots
222
+ if not has_bayer:
223
+ # OS screenshot path β€” pixel-perfect, no lens physics
224
+ if is_standard_res:
225
+ screenshot_score += 0.2
226
+ screenshot_traits += 1
227
+ indicators["standard_resolution"] = True
228
+ if low_color_count:
229
+ screenshot_score += 0.25
230
+ screenshot_traits += 1
231
+ indicators["low_color_count"] = max_unique
232
+ if hv_ratio > 0.6 and strong_edges_present:
233
+ screenshot_score += 0.25
234
+ screenshot_traits += 1
235
+ if uniform_sharpness:
236
+ screenshot_score += 0.15
237
+ if low_color_count:
238
+ screenshot_score += 0.1 # No sensor + few colors = UI render
239
+
240
+ # Gate: require at least 2 distinct UI traits AND at least one must be
241
+ # content-based (colors or edges), not just resolution match.
242
+ # This prevents smooth AI images at 1920Γ—1080 from false-triggering.
243
+ has_content_trait = low_color_count or (hv_ratio > 0.6 and strong_edges_present)
244
+ if screenshot_traits < 2 or not has_content_trait:
245
+ screenshot_score = 0.0
246
+
247
+ if screenshot_score >= 0.4:
248
+ scores["SCREENSHOT"] = min(1.0, screenshot_score)
249
  indicators["screenshot_detected"] = True
250
 
251
+ indicators["hv_ratio"] = round(hv_ratio, 3)
252
+ indicators["max_unique_colors"] = max_unique
253
+ indicators["sharpness_cv"] = round(sharpness_cv, 3)
254
+
255
  # ── Double JPEG detection (messaging) ─────────────────────────────
256
  hc, wc = (gray.shape[0] // 8) * 8, (gray.shape[1] // 8) * 8
257
  blockiness = 1.0
 
323
  conf = min(1.0, scores["PORTRAIT_MODE"])
324
 
325
  # SAFETY GUARD 1: No detail = possible AI
326
+ # Exception: SCREENSHOT modality is legitimately low-detail (UI renders exact colors,
327
+ # not photographic texture). If screenshot signals are strong, let it through.
328
+ if not has_detail and modality != "SCREENSHOT":
329
  modality = "UNKNOWN"
330
  conf = 0.2
331
  indicators["safety_override"] = "Low-detail image β€” suppression disabled"
332
+ elif not has_detail and modality == "SCREENSHOT" and scores.get("SCREENSHOT", 0) < 0.6:
333
+ # Weak screenshot signal + no detail β†’ still override
334
+ modality = "UNKNOWN"
335
+ conf = 0.2
336
+ indicators["safety_override"] = "Low-detail, weak screenshot signal β€” suppression disabled"
337
 
338
  # SAFETY GUARD 2: No real Bayer = not a real camera
339
  if not has_bayer and modality in ("PORTRAIT_MODE", "SMARTPHONE", "MESSAGING"):
 
408
  }
409
  elif modality == "SCREENSHOT":
410
  return {
411
+ # Optical physics β€” screenshots have no lens, suppress all lens tests
412
  "Vignetting cos⁴θ": 0.1, "Vignetting Symmetry": 0.1,
413
  "Lens Distortion": 0.1, "Field Curvature": 0.1,
414
  "CA Magnitude": 0.1, "CA Radial Gradient": 0.1, "Lateral CA": 0.1,
415
  "Purple Fringing": 0.1, "Bokeh Shape": 0.1,
416
+ "Sharpness Falloff": 0.1, "DoF Consistency": 0.1, "DoF Gradient Direction": 0.1,
417
+ "Diffraction Limit": 0.1, "Optical Center": 0.1,
418
+ # Sensor β€” screenshots have no camera sensor
419
  "PRNU Uniformity": 0.1, "Bayer CFA Pattern": 0.1, "CFA Nyquist": 0.1,
420
  "Hot/Dead Pixels": 0.1, "Noise Autocorrelation": 0.1, "Demosaic Interpolation": 0.1,
421
+ "PRNU Cross-Channel": 0.1, "Poisson-Gaussian Model": 0.1,
422
+ "Fixed Pattern Noise": 0.1, "Green Pixel Imbalance": 0.1,
423
+ # Generative model β€” LCD pixel grid creates false frequency artifacts
424
+ "Diffusion Notches": 0.15, "FFT Grid 8Γ—8": 0.3, "FFT Grid 16Γ—16": 0.3,
425
+ # Statistical β€” UI color distributions violate natural-image priors
426
+ "Benford's Law": 0.2, "Color Histogram": 0.2,
427
+ "DCT Kurtosis": 0.3, "Wavelet Kurtosis": 0.3,
428
+ "Gradient Sparsity": 0.3,
429
+ # Metadata β€” screenshots lack camera EXIF by definition
430
+ "EXIF Completeness": 0.1, "Maker Note": 0.1, "Thumbnail Check": 0.1,
431
+ "GPS Plausibility": 0.1, "ICC Color Profile": 0.2,
432
  }
433
  elif modality == "SMARTPHONE":
434
  return {