anky2002 commited on
Commit
f16ef2b
Β·
verified Β·
1 Parent(s): 31fe07c

Fix Bug 1: Relax macro detection thresholds - adaptive blur threshold (p40), add blur_uniformity as 5th indicator, lower bypass to 0.55, add debug logging

Browse files
Files changed (1) hide show
  1. agents/modality_detector.py +62 -34
agents/modality_detector.py CHANGED
@@ -1,13 +1,15 @@
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
@@ -33,9 +35,6 @@ def detect_modality(img: Image.Image) -> ModalityResult:
33
  rgb = np.array(img.convert("RGB")).astype(np.float64)
34
 
35
  # ═══ CRITICAL PRE-CHECK: Bayer CFA pattern ═══════════════════════
36
- # Real camera images have Bayer demosaicing traces: Οƒ_green < Οƒ_red β‰ˆ Οƒ_blue
37
- # If this is absent, the image CANNOT be from a real camera sensor.
38
- # This blocks portrait mode suppression from protecting AI images.
39
  noise_std = {}
40
  for c, nm in enumerate(["red", "green", "blue"]):
41
  ch = rgb[:, :, c]
@@ -52,8 +51,7 @@ def detect_modality(img: Image.Image) -> ModalityResult:
52
 
53
  # ═══ CONTENT-BASED DETECTION (works without metadata) ═════════════
54
 
55
- # ── Portrait mode detection ───────────────────────────────────────
56
- # Core signal: bimodal sharpness distribution (sharp fg + uniform blur bg)
57
  lap = np.array([[0, 1, 0], [1, -4, 1], [0, 1, 0]], dtype=np.float64)
58
  laplacian = convolve2d(gray, lap, mode="same", boundary="symm")
59
  sharpness = gaussian_filter(np.abs(laplacian), sigma=max(10, min(h, w) // 80))
@@ -69,10 +67,14 @@ def detect_modality(img: Image.Image) -> ModalityResult:
69
 
70
  # Sharp region detection
71
  sharp_thresh = p75
72
- blur_thresh = p25
73
  sharp_region = sharpness > sharp_thresh
74
- blur_region = sharpness < blur_thresh
75
  sharp_frac = float(np.mean(sharp_region))
 
 
 
 
 
 
76
  blur_frac = float(np.mean(blur_region))
77
 
78
  # Blur uniformity (computational blur is very uniform)
@@ -90,13 +92,13 @@ def detect_modality(img: Image.Image) -> ModalityResult:
90
 
91
  indicators["p95_sharpness"] = round(p95, 2)
92
  indicators["bimodal_ratio"] = round(bimodal_ratio, 3)
 
93
  indicators["blur_uniformity"] = round(blur_uniformity, 3)
94
  indicators["transition_abruptness"] = round(transition, 2)
95
  indicators["has_detail"] = has_detail
96
 
97
- # Portrait mode scoring β€” requires BOTH content signals AND real camera evidence
98
  # CRITICAL: If no Bayer pattern, this CANNOT be a smartphone portrait photo.
99
- # AI images that happen to have blurred backgrounds must NOT get portrait suppression.
100
  can_be_portrait = has_detail and has_bayer
101
 
102
  portrait_score = 0.0
@@ -114,19 +116,17 @@ def detect_modality(img: Image.Image) -> ModalityResult:
114
  indicators["portrait_detected"] = True
115
 
116
  # ── Macro/DSLR shallow DoF detection ─────────────────────────────
117
- # Macro photos have: extreme sharpness ratio (center vs edge), very high
118
- # bimodal ratio, large uniform blur region, and Bayer pattern present.
119
- # Key difference from portrait mode: sharpness ratio is much higher (>5)
120
- # because macro lenses produce more extreme DoF than phone portrait mode.
121
 
122
  # Compute center-vs-edge sharpness ratio
123
- ch, cw = gray.shape
124
- center_region = sharpness[ch//4:3*ch//4, cw//4:3*cw//4]
125
  edge_region = np.concatenate([
126
- sharpness[:ch//4, :].ravel(),
127
- sharpness[3*ch//4:, :].ravel(),
128
- sharpness[:, :cw//4].ravel(),
129
- sharpness[:, 3*cw//4:].ravel(),
130
  ])
131
  center_sharp = float(np.percentile(center_region, 90))
132
  edge_sharp = float(np.mean(edge_region))
@@ -138,20 +138,40 @@ def detect_modality(img: Image.Image) -> ModalityResult:
138
 
139
  indicators["sharpness_ratio"] = round(sharpness_ratio, 2)
140
  indicators["bg_color_std"] = round(bg_color_std, 2)
 
 
141
 
 
142
  macro_score = 0.0
 
 
143
  if has_detail and sharpness_ratio > 3.0:
144
- macro_score += 0.25 # Extreme center/edge sharpness difference
145
- if has_detail and blur_frac > 0.35:
146
- macro_score += 0.2 # Large blur region
 
 
 
 
 
147
  if has_detail and bimodal_ratio > 1.5:
148
- macro_score += 0.2 # Strong bimodal sharpness
 
 
149
  if has_detail and bg_color_std < 40:
150
- macro_score += 0.15 # Uniform background color (bokeh'd)
 
 
 
 
 
 
151
 
152
- # Macro requires Bayer OR high EXIF evidence (Unsplash strips some Bayer)
153
- # Allow macro without Bayer if other signals are very strong
154
- if macro_score >= 0.6:
 
 
155
  scores["MACRO_DSLR"] = macro_score
156
  indicators["macro_detected"] = True
157
  elif macro_score >= 0.4 and has_bayer:
@@ -253,17 +273,26 @@ def detect_modality(img: Image.Image) -> ModalityResult:
253
  indicators["safety_override"] = "Low-detail image β€” suppression disabled"
254
 
255
  # SAFETY GUARD 2: No Bayer pattern = not from a real camera sensor.
256
- # Exception: MACRO_DSLR with very strong signals can bypass this
257
  # (Unsplash CDN processing can strip Bayer traces from real DSLR photos)
258
  if not has_bayer and modality in ("PORTRAIT_MODE", "SMARTPHONE", "MESSAGING"):
259
  modality = "UNKNOWN"
260
  conf = 0.2
261
  indicators["safety_override"] = f"No Bayer CFA pattern (margin={bayer_margin:.3f}) β€” not from a real camera sensor. All suppression disabled."
262
- elif not has_bayer and modality == "MACRO_DSLR" and scores.get("MACRO_DSLR", 0) < 0.6:
263
  # Weak macro signal without Bayer β€” don't trust it
264
  modality = "UNKNOWN"
265
  conf = 0.2
266
- indicators["safety_override"] = f"Macro signal weak + no Bayer β€” suppression disabled"
 
 
 
 
 
 
 
 
 
267
 
268
  # ═══ BUILD ADJUSTMENTS ════════════════════════════════════════════
269
 
@@ -301,7 +330,6 @@ def _get_modality_adjustments(modality: str) -> dict:
301
  "PRNU Uniformity": 0.2,
302
  "Demosaic Interpolation": 0.4,
303
  # Bimodal content (sharp subject + smooth bokeh) creates extreme kurtosis
304
- # This is physics, not AI sharpening. Suppress the ceiling trigger.
305
  "DCT Kurtosis": 0.1,
306
  "Wavelet Kurtosis": 0.1,
307
  "Spectral Slope 1/fΒ²": 0.4,
 
1
  """
2
+ FORENSIQ β€” Capture Modality Detector v3
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 v3: Relaxed macro detection thresholds. blur_frac uses adaptive
8
+ threshold (p40 instead of p25) for more accurate blur region estimation.
9
+ Added blur_uniformity as 5th macro indicator. Debug logging to stderr.
10
  """
11
 
12
+ import sys
13
  import numpy as np
14
  from PIL import Image
15
  from scipy.ndimage import gaussian_filter, sobel
 
35
  rgb = np.array(img.convert("RGB")).astype(np.float64)
36
 
37
  # ═══ CRITICAL PRE-CHECK: Bayer CFA pattern ═══════════════════════
 
 
 
38
  noise_std = {}
39
  for c, nm in enumerate(["red", "green", "blue"]):
40
  ch = rgb[:, :, c]
 
51
 
52
  # ═══ CONTENT-BASED DETECTION (works without metadata) ═════════════
53
 
54
+ # ── Sharpness analysis (shared by portrait and macro) ─────────────
 
55
  lap = np.array([[0, 1, 0], [1, -4, 1], [0, 1, 0]], dtype=np.float64)
56
  laplacian = convolve2d(gray, lap, mode="same", boundary="symm")
57
  sharpness = gaussian_filter(np.abs(laplacian), sigma=max(10, min(h, w) // 80))
 
67
 
68
  # Sharp region detection
69
  sharp_thresh = p75
 
70
  sharp_region = sharpness > sharp_thresh
 
71
  sharp_frac = float(np.mean(sharp_region))
72
+
73
+ # Blur region: use ADAPTIVE threshold (p40 of sharpness)
74
+ # This gives a more meaningful blur fraction than the fixed p25 definition
75
+ # which is always ~25% by construction.
76
+ blur_adaptive_thresh = float(np.percentile(sharpness, 40))
77
+ blur_region = sharpness < blur_adaptive_thresh
78
  blur_frac = float(np.mean(blur_region))
79
 
80
  # Blur uniformity (computational blur is very uniform)
 
92
 
93
  indicators["p95_sharpness"] = round(p95, 2)
94
  indicators["bimodal_ratio"] = round(bimodal_ratio, 3)
95
+ indicators["blur_frac"] = round(blur_frac, 3)
96
  indicators["blur_uniformity"] = round(blur_uniformity, 3)
97
  indicators["transition_abruptness"] = round(transition, 2)
98
  indicators["has_detail"] = has_detail
99
 
100
+ # ── Portrait mode detection ───────────────────────────────────────
101
  # CRITICAL: If no Bayer pattern, this CANNOT be a smartphone portrait photo.
 
102
  can_be_portrait = has_detail and has_bayer
103
 
104
  portrait_score = 0.0
 
116
  indicators["portrait_detected"] = True
117
 
118
  # ── Macro/DSLR shallow DoF detection ─────────────────────────────
119
+ # Macro photos: extreme sharpness ratio (center vs edge), very high
120
+ # bimodal ratio, large blur region, uniform bokeh, and optionally Bayer.
 
 
121
 
122
  # Compute center-vs-edge sharpness ratio
123
+ ch_s, cw_s = gray.shape
124
+ center_region = sharpness[ch_s//4:3*ch_s//4, cw_s//4:3*cw_s//4]
125
  edge_region = np.concatenate([
126
+ sharpness[:ch_s//4, :].ravel(),
127
+ sharpness[3*ch_s//4:, :].ravel(),
128
+ sharpness[:, :cw_s//4].ravel(),
129
+ sharpness[:, 3*cw_s//4:].ravel(),
130
  ])
131
  center_sharp = float(np.percentile(center_region, 90))
132
  edge_sharp = float(np.mean(edge_region))
 
138
 
139
  indicators["sharpness_ratio"] = round(sharpness_ratio, 2)
140
  indicators["bg_color_std"] = round(bg_color_std, 2)
141
+ indicators["center_sharp_p90"] = round(center_sharp, 2)
142
+ indicators["edge_sharp_mean"] = round(edge_sharp, 2)
143
 
144
+ # ── Macro scoring (5 indicators) ─────────────────────────────────
145
  macro_score = 0.0
146
+ macro_components = []
147
+
148
  if has_detail and sharpness_ratio > 3.0:
149
+ macro_score += 0.25
150
+ macro_components.append(f"ratio={sharpness_ratio:.1f}")
151
+
152
+ if has_detail and blur_frac > 0.30:
153
+ # Relaxed from 0.35 to 0.30 β€” adaptive p40 threshold gives ~40% for macro
154
+ macro_score += 0.15
155
+ macro_components.append(f"blur={blur_frac:.2f}")
156
+
157
  if has_detail and bimodal_ratio > 1.5:
158
+ macro_score += 0.20
159
+ macro_components.append(f"bimodal={bimodal_ratio:.2f}")
160
+
161
  if has_detail and bg_color_std < 40:
162
+ macro_score += 0.15
163
+ macro_components.append(f"bg_std={bg_color_std:.1f}")
164
+
165
+ # NEW: blur uniformity β€” bokeh backgrounds are very uniform in blur intensity
166
+ if has_detail and blur_uniformity > 0.6:
167
+ macro_score += 0.15
168
+ macro_components.append(f"blur_uni={blur_uniformity:.2f}")
169
 
170
+ indicators["macro_score"] = round(macro_score, 3)
171
+ indicators["macro_components"] = macro_components
172
+
173
+ # Macro requires Bayer OR high signal strength (Unsplash strips Bayer traces)
174
+ if macro_score >= 0.55:
175
  scores["MACRO_DSLR"] = macro_score
176
  indicators["macro_detected"] = True
177
  elif macro_score >= 0.4 and has_bayer:
 
273
  indicators["safety_override"] = "Low-detail image β€” suppression disabled"
274
 
275
  # SAFETY GUARD 2: No Bayer pattern = not from a real camera sensor.
276
+ # Exception: MACRO_DSLR with strong signals can bypass this
277
  # (Unsplash CDN processing can strip Bayer traces from real DSLR photos)
278
  if not has_bayer and modality in ("PORTRAIT_MODE", "SMARTPHONE", "MESSAGING"):
279
  modality = "UNKNOWN"
280
  conf = 0.2
281
  indicators["safety_override"] = f"No Bayer CFA pattern (margin={bayer_margin:.3f}) β€” not from a real camera sensor. All suppression disabled."
282
+ elif not has_bayer and modality == "MACRO_DSLR" and scores.get("MACRO_DSLR", 0) < 0.55:
283
  # Weak macro signal without Bayer β€” don't trust it
284
  modality = "UNKNOWN"
285
  conf = 0.2
286
+ indicators["safety_override"] = f"Macro signal weak ({scores.get('MACRO_DSLR', 0):.2f}) + no Bayer β€” suppression disabled"
287
+
288
+ # ═══ DEBUG LOGGING ════════════════════════════════════════════════
289
+ print(f"[MODALITY] detected={modality} conf={conf:.2f} scores={scores}", file=sys.stderr)
290
+ print(f"[MODALITY] has_bayer={has_bayer} bayer_margin={bayer_margin:.4f}", file=sys.stderr)
291
+ print(f"[MODALITY] macro_score={macro_score:.3f} components={macro_components}", file=sys.stderr)
292
+ print(f"[MODALITY] sharpness_ratio={sharpness_ratio:.2f} bimodal={bimodal_ratio:.3f} blur_frac={blur_frac:.3f} blur_uni={blur_uniformity:.3f} bg_std={bg_color_std:.2f}", file=sys.stderr)
293
+ print(f"[MODALITY] p95={p95:.2f} has_detail={has_detail}", file=sys.stderr)
294
+ if indicators.get("safety_override"):
295
+ print(f"[MODALITY] SAFETY OVERRIDE: {indicators['safety_override']}", file=sys.stderr)
296
 
297
  # ═══ BUILD ADJUSTMENTS ════════════════════════════════════════════
298
 
 
330
  "PRNU Uniformity": 0.2,
331
  "Demosaic Interpolation": 0.4,
332
  # Bimodal content (sharp subject + smooth bokeh) creates extreme kurtosis
 
333
  "DCT Kurtosis": 0.1,
334
  "Wavelet Kurtosis": 0.1,
335
  "Spectral Slope 1/fΒ²": 0.4,