anky2002 commited on
Commit
2d481fb
Β·
verified Β·
1 Parent(s): 0552d0c

v5: Add minimum Bayer margin (0.005) to prevent JPEG-induced false Bayer detection on AI images

Browse files
Files changed (1) hide show
  1. agents/modality_detector.py +50 -104
agents/modality_detector.py CHANGED
@@ -1,12 +1,10 @@
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
@@ -25,6 +23,12 @@ class ModalityResult:
25
  score_adjustments: dict
26
 
27
 
 
 
 
 
 
 
28
  def detect_modality(img: Image.Image) -> ModalityResult:
29
  """Detect capture modality from image content and metadata."""
30
  indicators = {}
@@ -35,23 +39,29 @@ def detect_modality(img: Image.Image) -> ModalityResult:
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]
41
  dn = gaussian_filter(ch, sigma=1.5)
42
  noise_std[nm] = float(np.std(ch - dn))
43
 
44
- has_bayer = noise_std["green"] < min(noise_std["red"], noise_std["blue"])
45
  bayer_margin = min(noise_std["red"], noise_std["blue"]) - noise_std["green"]
 
 
 
 
46
  indicators["has_bayer"] = has_bayer
47
  indicators["bayer_margin"] = round(bayer_margin, 4)
 
48
  indicators["noise_g"] = round(noise_std["green"], 3)
49
  indicators["noise_r"] = round(noise_std["red"], 3)
50
  indicators["noise_b"] = round(noise_std["blue"], 3)
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))
@@ -61,32 +71,26 @@ def detect_modality(img: Image.Image) -> ModalityResult:
61
  p75 = float(np.percentile(sharpness, 75))
62
  p95 = float(np.percentile(sharpness, 95))
63
 
64
- # Bimodality: large gap between p25 and p75
65
  iqr = p75 - p25
66
  bimodal_ratio = iqr / (p50 + 1e-9)
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 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
 
83
- # Transition abruptness (computational segmentation = sharp boundary)
84
  sharpness_grad = np.hypot(sobel(sharpness, 0), sobel(sharpness, 1))
85
  max_grad = float(np.percentile(sharpness_grad, 99))
86
  mean_grad = float(np.mean(sharpness_grad))
87
  transition = max_grad / (mean_grad + 1e-9)
88
 
89
- # Absolute detail level (real photos have p95 > 5 even after heavy compression)
90
  has_detail = p95 > 5.0
91
 
92
  indicators["p95_sharpness"] = round(p95, 2)
@@ -114,11 +118,6 @@ def detect_modality(img: Image.Image) -> ModalityResult:
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,7 +130,6 @@ def detect_modality(img: Image.Image) -> ModalityResult:
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,33 +138,24 @@ def detect_modality(img: Image.Image) -> ModalityResult:
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}")
@@ -175,7 +164,6 @@ def detect_modality(img: Image.Image) -> ModalityResult:
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
@@ -208,7 +196,7 @@ def detect_modality(img: Image.Image) -> ModalityResult:
208
 
209
  indicators["blockiness"] = round(blockiness, 3)
210
 
211
- # ═══ METADATA-BASED DETECTION (bonus signals) ═════════════════════
212
 
213
  try:
214
  exif = img._getexif() or {}
@@ -226,7 +214,6 @@ def detect_modality(img: Image.Image) -> ModalityResult:
226
  indicators["has_exif"] = has_exif
227
  indicators["format"] = getattr(img, 'format', None)
228
 
229
- # Phone brand in EXIF
230
  phone_brands = ["apple", "samsung", "google", "pixel", "huawei", "xiaomi", "oneplus",
231
  "oppo", "vivo", "realme", "motorola", "lg", "nothing"]
232
  make = decoded.get("Make", "").lower()
@@ -237,13 +224,11 @@ def detect_modality(img: Image.Image) -> ModalityResult:
237
  scores["SMARTPHONE"] = scores.get("SMARTPHONE", 0) + 0.4
238
  indicators["phone_brand"] = True
239
 
240
- # Rich EXIF with lens β†’ DSLR
241
  cam_fields = sum(["Make" in decoded, "Model" in decoded,
242
  "LensModel" in decoded or "LensInfo" in decoded, "FocalLength" in decoded])
243
  if cam_fields >= 3 and ("LensModel" in decoded or "LensInfo" in decoded):
244
  scores["DSLR"] = scores.get("DSLR", 0) + 0.5
245
 
246
- # No EXIF + low res + double JPEG β†’ messaging
247
  max_side = max(w, h)
248
  no_exif_low_res = not has_exif and max_side <= 1600
249
  if no_exif_low_res:
@@ -262,35 +247,32 @@ def detect_modality(img: Image.Image) -> ModalityResult:
262
  modality = max(scores, key=scores.get)
263
  conf = min(1.0, scores[modality])
264
 
265
- # Macro DSLR wins over portrait mode when detected (more specific)
266
  if scores.get("MACRO_DSLR", 0) >= 0.4:
267
  modality = "MACRO_DSLR"
268
  conf = min(1.0, scores["MACRO_DSLR"])
269
- # Portrait mode wins when detected and no macro
270
  elif scores.get("PORTRAIT_MODE", 0) > 0.3:
271
  modality = "PORTRAIT_MODE"
272
  conf = min(1.0, scores["PORTRAIT_MODE"])
273
 
274
- # SAFETY GUARD 1: No detail = possible AI. Disable all suppression.
275
  if not has_detail:
276
  modality = "UNKNOWN"
277
  conf = 0.2
278
  indicators["safety_override"] = "Low-detail image β€” suppression disabled"
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"
290
 
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)
@@ -301,14 +283,12 @@ def detect_modality(img: Image.Image) -> ModalityResult:
301
 
302
  adjustments = _get_modality_adjustments(modality)
303
 
304
- # Merge messaging adjustments when portrait + messaging both detected
305
  if modality == "PORTRAIT_MODE" and scores.get("MESSAGING", 0) > 0.15:
306
  msg_adj = _get_modality_adjustments("MESSAGING")
307
  for k, v in msg_adj.items():
308
  adjustments[k] = min(adjustments.get(k, 1.0), v)
309
  indicators["dual_modality"] = "PORTRAIT_MODE + MESSAGING"
310
 
311
- # Merge messaging adjustments when macro + messaging both detected
312
  if modality == "MACRO_DSLR" and scores.get("MESSAGING", 0) > 0.15:
313
  msg_adj = _get_modality_adjustments("MESSAGING")
314
  for k, v in msg_adj.items():
@@ -323,64 +303,32 @@ def detect_modality(img: Image.Image) -> ModalityResult:
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,
341
- "Pixel Response Linearity": 0.4,
342
- "Saturation Clipping": 0.4,
343
- "VAE Patch Boundaries": 0.3,
344
  }
345
  elif modality == "PORTRAIT_MODE":
346
  return {
347
- "Autocorrelation Peak": 0.1,
348
- "Texture Repetition": 0.1,
349
- "VAE Patch Boundaries": 0.2,
350
- "PRNU Uniformity": 0.15,
351
- "Poisson-Gaussian Model": 0.3,
352
- "DoF Consistency": 0.2,
353
- "Vignetting cos⁴θ": 0.3,
354
- "HF Noise Structure": 0.3,
355
- "Noise Spatial Frequency": 0.3,
356
- "CFA Nyquist": 0.25,
357
- "Spectral Slope 1/fΒ²": 0.5,
358
- "Spectral Symmetry": 0.4,
359
- "Phase Coherence": 0.4,
360
- "Pixel Response Linearity": 0.3,
361
- "Demosaic Interpolation": 0.4,
362
- "Saturation Clipping": 0.4,
363
  }
364
  elif modality == "MESSAGING":
365
  return {
366
- "EXIF Completeness": 0.15,
367
- "Compression Ghosts": 0.2,
368
- "ICC Color Profile": 0.2,
369
- "Maker Note": 0.2,
370
- "Thumbnail Check": 0.2,
371
- "Software Detection": 0.2,
372
- "JPEG Quantization": 0.3,
373
- "CFA Nyquist": 0.5,
374
- "Watermark Detection": 0.2,
375
- "Demosaic Interpolation": 0.5,
376
- }
377
- elif modality == "SOCIAL_MEDIA":
378
- return {
379
- "EXIF Completeness": 0.2,
380
- "Compression Ghosts": 0.3,
381
- "ICC Color Profile": 0.3,
382
- "Maker Note": 0.2,
383
- "Thumbnail Check": 0.3,
384
  }
385
  elif modality == "SCREENSHOT":
386
  return {
@@ -393,10 +341,8 @@ def _get_modality_adjustments(modality: str) -> dict:
393
  }
394
  elif modality == "SMARTPHONE":
395
  return {
396
- "Vignetting cos⁴θ": 0.5,
397
- "CFA Nyquist": 0.7,
398
- "Poisson-Gaussian Model": 0.7,
399
- "Pixel Response Linearity": 0.4,
400
  "Spectral Slope 1/fΒ²": 0.7,
401
  }
402
  else:
 
1
  """
2
+ FORENSIQ β€” Capture Modality Detector v5
3
 
4
+ v5: Added minimum bayer_margin threshold (0.005) to prevent JPEG re-encoding
5
+ from creating false Bayer CFA patterns on AI images. Real cameras have
6
+ bayer_margin > 0.01; AI images through JPEG have margin < 0.001.
7
+ Also: sharpness_ratio > 3.0 hard gate for macro detection.
 
 
8
  """
9
 
10
  import sys
 
23
  score_adjustments: dict
24
 
25
 
26
+ # Minimum Bayer CFA margin β€” below this, the "Bayer" signal is likely
27
+ # JPEG encoding artifacts, not a real camera sensor signature.
28
+ # Real cameras: margin > 0.01. JPEG artifacts: margin < 0.001.
29
+ MIN_BAYER_MARGIN = 0.005
30
+
31
+
32
  def detect_modality(img: Image.Image) -> ModalityResult:
33
  """Detect capture modality from image content and metadata."""
34
  indicators = {}
 
39
  rgb = np.array(img.convert("RGB")).astype(np.float64)
40
 
41
  # ═══ CRITICAL PRE-CHECK: Bayer CFA pattern ═══════════════════════
42
+ # Real camera: Οƒ_green < Οƒ_red β‰ˆ Οƒ_blue (green has 2x sampling density)
43
+ # AI image through JPEG: all channels have ~same noise (margin β‰ˆ 0)
44
  noise_std = {}
45
  for c, nm in enumerate(["red", "green", "blue"]):
46
  ch = rgb[:, :, c]
47
  dn = gaussian_filter(ch, sigma=1.5)
48
  noise_std[nm] = float(np.std(ch - dn))
49
 
 
50
  bayer_margin = min(noise_std["red"], noise_std["blue"]) - noise_std["green"]
51
+ # Require BOTH: green < min(red, blue) AND margin above noise floor
52
+ has_bayer = (noise_std["green"] < min(noise_std["red"], noise_std["blue"])
53
+ and bayer_margin > MIN_BAYER_MARGIN)
54
+
55
  indicators["has_bayer"] = has_bayer
56
  indicators["bayer_margin"] = round(bayer_margin, 4)
57
+ indicators["bayer_margin_threshold"] = MIN_BAYER_MARGIN
58
  indicators["noise_g"] = round(noise_std["green"], 3)
59
  indicators["noise_r"] = round(noise_std["red"], 3)
60
  indicators["noise_b"] = round(noise_std["blue"], 3)
61
 
62
+ # ═══ CONTENT-BASED DETECTION ═════════════════════════════════════
63
 
64
+ # ── Sharpness analysis ────────────────────────────────────────────
65
  lap = np.array([[0, 1, 0], [1, -4, 1], [0, 1, 0]], dtype=np.float64)
66
  laplacian = convolve2d(gray, lap, mode="same", boundary="symm")
67
  sharpness = gaussian_filter(np.abs(laplacian), sigma=max(10, min(h, w) // 80))
 
71
  p75 = float(np.percentile(sharpness, 75))
72
  p95 = float(np.percentile(sharpness, 95))
73
 
 
74
  iqr = p75 - p25
75
  bimodal_ratio = iqr / (p50 + 1e-9)
76
 
 
77
  sharp_thresh = p75
78
  sharp_region = sharpness > sharp_thresh
79
  sharp_frac = float(np.mean(sharp_region))
80
 
81
+ # Blur region: content-based threshold (20% of p95)
 
82
  blur_content_thresh = p95 * 0.20
83
  blur_region = sharpness < blur_content_thresh
84
  blur_frac = float(np.mean(blur_region))
85
 
 
86
  blur_vals = sharpness[blur_region] if np.any(blur_region) else np.array([1])
87
  blur_uniformity = 1.0 - min(float(np.std(blur_vals)) / (float(np.mean(blur_vals)) + 1e-9), 1.0)
88
 
 
89
  sharpness_grad = np.hypot(sobel(sharpness, 0), sobel(sharpness, 1))
90
  max_grad = float(np.percentile(sharpness_grad, 99))
91
  mean_grad = float(np.mean(sharpness_grad))
92
  transition = max_grad / (mean_grad + 1e-9)
93
 
 
94
  has_detail = p95 > 5.0
95
 
96
  indicators["p95_sharpness"] = round(p95, 2)
 
118
  indicators["portrait_detected"] = True
119
 
120
  # ── Macro/DSLR shallow DoF detection ─────────────────────────────
 
 
 
 
 
121
  ch_s, cw_s = gray.shape
122
  center_region = sharpness[ch_s//4:3*ch_s//4, cw_s//4:3*cw_s//4]
123
  edge_region = np.concatenate([
 
130
  edge_sharp = float(np.mean(edge_region))
131
  sharpness_ratio = center_sharp / (edge_sharp + 1e-9)
132
 
 
133
  blur_region_pixels = gray[blur_region] if np.any(blur_region) else np.array([128])
134
  bg_color_std = float(np.std(blur_region_pixels))
135
 
 
138
  indicators["center_sharp_p90"] = round(center_sharp, 2)
139
  indicators["edge_sharp_mean"] = round(edge_sharp, 2)
140
 
141
+ # ── Macro scoring (GATED by sharpness_ratio > 3.0) ───────────────
 
 
 
142
  macro_score = 0.0
143
  macro_components = []
 
144
  macro_gate_passed = has_detail and sharpness_ratio > 3.0
145
 
146
  if macro_gate_passed:
 
147
  macro_score += 0.25
148
  macro_components.append(f"ratio={sharpness_ratio:.1f}")
149
 
 
150
  if blur_frac > 0.25:
151
  macro_score += 0.15
152
  macro_components.append(f"blur={blur_frac:.2f}")
 
153
  if bimodal_ratio > 1.5:
154
  macro_score += 0.20
155
  macro_components.append(f"bimodal={bimodal_ratio:.2f}")
 
156
  if bg_color_std < 40:
157
  macro_score += 0.15
158
  macro_components.append(f"bg_std={bg_color_std:.1f}")
 
159
  if blur_uniformity > 0.6:
160
  macro_score += 0.15
161
  macro_components.append(f"blur_uni={blur_uniformity:.2f}")
 
164
  indicators["macro_components"] = macro_components
165
  indicators["macro_gate_passed"] = macro_gate_passed
166
 
 
167
  if macro_score >= 0.55:
168
  scores["MACRO_DSLR"] = macro_score
169
  indicators["macro_detected"] = True
 
196
 
197
  indicators["blockiness"] = round(blockiness, 3)
198
 
199
+ # ═══ METADATA-BASED DETECTION ═════════════════════════════════════
200
 
201
  try:
202
  exif = img._getexif() or {}
 
214
  indicators["has_exif"] = has_exif
215
  indicators["format"] = getattr(img, 'format', None)
216
 
 
217
  phone_brands = ["apple", "samsung", "google", "pixel", "huawei", "xiaomi", "oneplus",
218
  "oppo", "vivo", "realme", "motorola", "lg", "nothing"]
219
  make = decoded.get("Make", "").lower()
 
224
  scores["SMARTPHONE"] = scores.get("SMARTPHONE", 0) + 0.4
225
  indicators["phone_brand"] = True
226
 
 
227
  cam_fields = sum(["Make" in decoded, "Model" in decoded,
228
  "LensModel" in decoded or "LensInfo" in decoded, "FocalLength" in decoded])
229
  if cam_fields >= 3 and ("LensModel" in decoded or "LensInfo" in decoded):
230
  scores["DSLR"] = scores.get("DSLR", 0) + 0.5
231
 
 
232
  max_side = max(w, h)
233
  no_exif_low_res = not has_exif and max_side <= 1600
234
  if no_exif_low_res:
 
247
  modality = max(scores, key=scores.get)
248
  conf = min(1.0, scores[modality])
249
 
 
250
  if scores.get("MACRO_DSLR", 0) >= 0.4:
251
  modality = "MACRO_DSLR"
252
  conf = min(1.0, scores["MACRO_DSLR"])
 
253
  elif scores.get("PORTRAIT_MODE", 0) > 0.3:
254
  modality = "PORTRAIT_MODE"
255
  conf = min(1.0, scores["PORTRAIT_MODE"])
256
 
257
+ # SAFETY GUARD 1: No detail = possible AI
258
  if not has_detail:
259
  modality = "UNKNOWN"
260
  conf = 0.2
261
  indicators["safety_override"] = "Low-detail image β€” suppression disabled"
262
 
263
+ # SAFETY GUARD 2: No real Bayer = not a real camera
 
264
  if not has_bayer and modality in ("PORTRAIT_MODE", "SMARTPHONE", "MESSAGING"):
265
  modality = "UNKNOWN"
266
  conf = 0.2
267
+ indicators["safety_override"] = f"No Bayer CFA (margin={bayer_margin:.4f} < {MIN_BAYER_MARGIN}) β€” suppression disabled"
268
  elif not has_bayer and modality == "MACRO_DSLR" and scores.get("MACRO_DSLR", 0) < 0.55:
269
  modality = "UNKNOWN"
270
  conf = 0.2
271
+ indicators["safety_override"] = f"Macro weak ({scores.get('MACRO_DSLR', 0):.2f}) + no Bayer β€” suppression disabled"
272
 
273
  # ═══ DEBUG LOGGING ════════════════════════════════════════════════
274
  print(f"[MODALITY] detected={modality} conf={conf:.2f} scores={scores}", file=sys.stderr)
275
+ print(f"[MODALITY] has_bayer={has_bayer} margin={bayer_margin:.4f} (min={MIN_BAYER_MARGIN})", file=sys.stderr)
276
  print(f"[MODALITY] macro_score={macro_score:.3f} gate={macro_gate_passed} components={macro_components}", file=sys.stderr)
277
  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)
278
  print(f"[MODALITY] p95={p95:.2f} has_detail={has_detail}", file=sys.stderr)
 
283
 
284
  adjustments = _get_modality_adjustments(modality)
285
 
 
286
  if modality == "PORTRAIT_MODE" and scores.get("MESSAGING", 0) > 0.15:
287
  msg_adj = _get_modality_adjustments("MESSAGING")
288
  for k, v in msg_adj.items():
289
  adjustments[k] = min(adjustments.get(k, 1.0), v)
290
  indicators["dual_modality"] = "PORTRAIT_MODE + MESSAGING"
291
 
 
292
  if modality == "MACRO_DSLR" and scores.get("MESSAGING", 0) > 0.15:
293
  msg_adj = _get_modality_adjustments("MESSAGING")
294
  for k, v in msg_adj.items():
 
303
  def _get_modality_adjustments(modality: str) -> dict:
304
  if modality == "MACRO_DSLR":
305
  return {
306
+ "Autocorrelation Peak": 0.1, "Texture Repetition": 0.1, "DoF Consistency": 0.1,
307
+ "Bayer CFA Pattern": 0.3, "CFA Nyquist": 0.3, "PRNU Uniformity": 0.2,
308
+ "Demosaic Interpolation": 0.4, "DCT Kurtosis": 0.1, "Wavelet Kurtosis": 0.1,
309
+ "Spectral Slope 1/fΒ²": 0.4, "Spectral Symmetry": 0.3, "Phase Coherence": 0.4,
310
+ "Noise Spatial Frequency": 0.2, "Poisson-Gaussian Model": 0.2,
311
+ "HF Noise Structure": 0.3, "Pixel Response Linearity": 0.4,
312
+ "Saturation Clipping": 0.4, "VAE Patch Boundaries": 0.3,
 
 
 
 
 
 
 
 
 
 
 
313
  }
314
  elif modality == "PORTRAIT_MODE":
315
  return {
316
+ "Autocorrelation Peak": 0.1, "Texture Repetition": 0.1,
317
+ "VAE Patch Boundaries": 0.2, "PRNU Uniformity": 0.15,
318
+ "Poisson-Gaussian Model": 0.3, "DoF Consistency": 0.2,
319
+ "Vignetting cos⁴θ": 0.3, "HF Noise Structure": 0.3,
320
+ "Noise Spatial Frequency": 0.3, "CFA Nyquist": 0.25,
321
+ "Spectral Slope 1/fΒ²": 0.5, "Spectral Symmetry": 0.4,
322
+ "Phase Coherence": 0.4, "Pixel Response Linearity": 0.3,
323
+ "Demosaic Interpolation": 0.4, "Saturation Clipping": 0.4,
 
 
 
 
 
 
 
 
324
  }
325
  elif modality == "MESSAGING":
326
  return {
327
+ "EXIF Completeness": 0.15, "Compression Ghosts": 0.2,
328
+ "ICC Color Profile": 0.2, "Maker Note": 0.2,
329
+ "Thumbnail Check": 0.2, "Software Detection": 0.2,
330
+ "JPEG Quantization": 0.3, "CFA Nyquist": 0.5,
331
+ "Watermark Detection": 0.2, "Demosaic Interpolation": 0.5,
 
 
 
 
 
 
 
 
 
 
 
 
 
332
  }
333
  elif modality == "SCREENSHOT":
334
  return {
 
341
  }
342
  elif modality == "SMARTPHONE":
343
  return {
344
+ "Vignetting cos⁴θ": 0.5, "CFA Nyquist": 0.7,
345
+ "Poisson-Gaussian Model": 0.7, "Pixel Response Linearity": 0.4,
 
 
346
  "Spectral Slope 1/fΒ²": 0.7,
347
  }
348
  else: