Fix macro false positive on landscapes: sharpness_ratio > 3.0 is now a hard gating requirement for MACRO_DSLR
Browse files- agents/modality_detector.py +42 -43
agents/modality_detector.py
CHANGED
|
@@ -1,12 +1,12 @@
|
|
| 1 |
"""
|
| 2 |
-
FORENSIQ β Capture Modality Detector
|
| 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 |
-
|
| 8 |
-
|
| 9 |
-
|
| 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
|
| 74 |
-
# This
|
| 75 |
-
|
| 76 |
-
|
| 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 |
-
#
|
| 120 |
-
#
|
|
|
|
|
|
|
| 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
|
| 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 (
|
|
|
|
|
|
|
|
|
|
| 145 |
macro_score = 0.0
|
| 146 |
macro_components = []
|
| 147 |
|
| 148 |
-
|
|
|
|
|
|
|
|
|
|
| 149 |
macro_score += 0.25
|
| 150 |
macro_components.append(f"ratio={sharpness_ratio:.1f}")
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 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
|
| 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,
|