anky2002 commited on
Commit
0552d0c
Β·
verified Β·
1 Parent(s): 214e657

Fix macro false positive on landscapes: sharpness_ratio > 3.0 is now a hard gating requirement for MACRO_DSLR

Browse files
Files changed (1) hide show
  1. agents/modality_detector.py +42 -43
agents/modality_detector.py CHANGED
@@ -1,12 +1,12 @@
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
@@ -70,14 +70,13 @@ def detect_modality(img: Image.Image) -> ModalityResult:
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)
81
  blur_vals = sharpness[blur_region] if np.any(blur_region) else np.array([1])
82
  blur_uniformity = 1.0 - min(float(np.std(blur_vals)) / (float(np.mean(blur_vals)) + 1e-9), 1.0)
83
 
@@ -98,7 +97,6 @@ def detect_modality(img: Image.Image) -> ModalityResult:
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,10 +114,11 @@ def detect_modality(img: Image.Image) -> ModalityResult:
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([
@@ -132,7 +131,7 @@ def detect_modality(img: Image.Image) -> ModalityResult:
132
  edge_sharp = float(np.mean(edge_region))
133
  sharpness_ratio = center_sharp / (edge_sharp + 1e-9)
134
 
135
- # Background uniformity: how uniform is the blurred region's color?
136
  blur_region_pixels = gray[blur_region] if np.any(blur_region) else np.array([128])
137
  bg_color_std = float(np.std(blur_region_pixels))
138
 
@@ -141,36 +140,42 @@ def detect_modality(img: Image.Image) -> ModalityResult:
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
@@ -274,13 +279,11 @@ def detect_modality(img: Image.Image) -> ModalityResult:
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"
@@ -288,7 +291,7 @@ def detect_modality(img: Image.Image) -> ModalityResult:
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"):
@@ -320,22 +323,18 @@ def detect_modality(img: Image.Image) -> ModalityResult:
320
  def _get_modality_adjustments(modality: str) -> dict:
321
  if modality == "MACRO_DSLR":
322
  return {
323
- # Extreme shallow DoF creates uniform bokeh β†’ high autocorrelation
324
  "Autocorrelation Peak": 0.1,
325
  "Texture Repetition": 0.1,
326
  "DoF Consistency": 0.1,
327
- # JPEG delivery pipeline removes sensor traces
328
  "Bayer CFA Pattern": 0.3,
329
  "CFA Nyquist": 0.3,
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,
336
  "Spectral Symmetry": 0.3,
337
  "Phase Coherence": 0.4,
338
- # Noise inconsistency between sharp subject and bokeh
339
  "Noise Spatial Frequency": 0.2,
340
  "Poisson-Gaussian Model": 0.2,
341
  "HF Noise Structure": 0.3,
 
1
  """
2
+ FORENSIQ β€” Capture Modality Detector v4
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
+ v4 fix: sharpness_ratio > 3.0 is now a HARD requirement for macro detection.
8
+ Without extreme center-vs-edge sharpness, other indicators (bimodal ratio,
9
+ background uniformity) are not sufficient to distinguish macro from landscape.
10
  """
11
 
12
  import sys
 
70
  sharp_region = sharpness > sharp_thresh
71
  sharp_frac = float(np.mean(sharp_region))
72
 
73
+ # Blur region: use content-based threshold (20% of p95)
74
+ # This is more meaningful than percentile-based β€” it measures actual blur
75
+ blur_content_thresh = p95 * 0.20
76
+ blur_region = sharpness < blur_content_thresh
 
77
  blur_frac = float(np.mean(blur_region))
78
 
79
+ # Blur uniformity (computational/optical blur is very uniform)
80
  blur_vals = sharpness[blur_region] if np.any(blur_region) else np.array([1])
81
  blur_uniformity = 1.0 - min(float(np.std(blur_vals)) / (float(np.mean(blur_vals)) + 1e-9), 1.0)
82
 
 
97
  indicators["has_detail"] = has_detail
98
 
99
  # ── Portrait mode detection ───────────────────────────────────────
 
100
  can_be_portrait = has_detail and has_bayer
101
 
102
  portrait_score = 0.0
 
114
  indicators["portrait_detected"] = True
115
 
116
  # ── Macro/DSLR shallow DoF detection ─────────────────────────────
117
+ # KEY INSIGHT: The defining feature of macro photography is EXTREME
118
+ # center-vs-edge sharpness difference (>3x). Without this, an image
119
+ # cannot be macro β€” it could be a landscape with fog/haze that mimics
120
+ # bimodal sharpness distribution.
121
 
 
122
  ch_s, cw_s = gray.shape
123
  center_region = sharpness[ch_s//4:3*ch_s//4, cw_s//4:3*cw_s//4]
124
  edge_region = np.concatenate([
 
131
  edge_sharp = float(np.mean(edge_region))
132
  sharpness_ratio = center_sharp / (edge_sharp + 1e-9)
133
 
134
+ # Background uniformity
135
  blur_region_pixels = gray[blur_region] if np.any(blur_region) else np.array([128])
136
  bg_color_std = float(np.std(blur_region_pixels))
137
 
 
140
  indicators["center_sharp_p90"] = round(center_sharp, 2)
141
  indicators["edge_sharp_mean"] = round(edge_sharp, 2)
142
 
143
+ # ── Macro scoring (GATED by sharpness_ratio) ─────────────────────
144
+ # sharpness_ratio > 3.0 is a HARD REQUIREMENT β€” other signals alone
145
+ # are not sufficient to distinguish macro from landscape/portrait.
146
+
147
  macro_score = 0.0
148
  macro_components = []
149
 
150
+ macro_gate_passed = has_detail and sharpness_ratio > 3.0
151
+
152
+ if macro_gate_passed:
153
+ # Base score for passing the hard gate
154
  macro_score += 0.25
155
  macro_components.append(f"ratio={sharpness_ratio:.1f}")
156
+
157
+ # Additional indicators (each adds confidence)
158
+ if blur_frac > 0.25:
159
+ macro_score += 0.15
160
+ macro_components.append(f"blur={blur_frac:.2f}")
161
+
162
+ if bimodal_ratio > 1.5:
163
+ macro_score += 0.20
164
+ macro_components.append(f"bimodal={bimodal_ratio:.2f}")
165
+
166
+ if bg_color_std < 40:
167
+ macro_score += 0.15
168
+ macro_components.append(f"bg_std={bg_color_std:.1f}")
169
+
170
+ if blur_uniformity > 0.6:
171
+ macro_score += 0.15
172
+ macro_components.append(f"blur_uni={blur_uniformity:.2f}")
 
173
 
174
  indicators["macro_score"] = round(macro_score, 3)
175
  indicators["macro_components"] = macro_components
176
+ indicators["macro_gate_passed"] = macro_gate_passed
177
 
178
+ # Macro requires GATE + (Bayer OR high signal strength)
179
  if macro_score >= 0.55:
180
  scores["MACRO_DSLR"] = macro_score
181
  indicators["macro_detected"] = True
 
279
 
280
  # SAFETY GUARD 2: No Bayer pattern = not from a real camera sensor.
281
  # Exception: MACRO_DSLR with strong signals can bypass this
 
282
  if not has_bayer and modality in ("PORTRAIT_MODE", "SMARTPHONE", "MESSAGING"):
283
  modality = "UNKNOWN"
284
  conf = 0.2
285
  indicators["safety_override"] = f"No Bayer CFA pattern (margin={bayer_margin:.3f}) β€” not from a real camera sensor. All suppression disabled."
286
  elif not has_bayer and modality == "MACRO_DSLR" and scores.get("MACRO_DSLR", 0) < 0.55:
 
287
  modality = "UNKNOWN"
288
  conf = 0.2
289
  indicators["safety_override"] = f"Macro signal weak ({scores.get('MACRO_DSLR', 0):.2f}) + no Bayer β€” suppression disabled"
 
291
  # ═══ DEBUG LOGGING ════════════════════════════════════════════════
292
  print(f"[MODALITY] detected={modality} conf={conf:.2f} scores={scores}", file=sys.stderr)
293
  print(f"[MODALITY] has_bayer={has_bayer} bayer_margin={bayer_margin:.4f}", file=sys.stderr)
294
+ print(f"[MODALITY] macro_score={macro_score:.3f} gate={macro_gate_passed} components={macro_components}", file=sys.stderr)
295
  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)
296
  print(f"[MODALITY] p95={p95:.2f} has_detail={has_detail}", file=sys.stderr)
297
  if indicators.get("safety_override"):
 
323
  def _get_modality_adjustments(modality: str) -> dict:
324
  if modality == "MACRO_DSLR":
325
  return {
 
326
  "Autocorrelation Peak": 0.1,
327
  "Texture Repetition": 0.1,
328
  "DoF Consistency": 0.1,
 
329
  "Bayer CFA Pattern": 0.3,
330
  "CFA Nyquist": 0.3,
331
  "PRNU Uniformity": 0.2,
332
  "Demosaic Interpolation": 0.4,
 
333
  "DCT Kurtosis": 0.1,
334
  "Wavelet Kurtosis": 0.1,
335
  "Spectral Slope 1/fΒ²": 0.4,
336
  "Spectral Symmetry": 0.3,
337
  "Phase Coherence": 0.4,
 
338
  "Noise Spatial Frequency": 0.2,
339
  "Poisson-Gaussian Model": 0.2,
340
  "HF Noise Structure": 0.3,