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- agents/modality_detector.py +62 -34
agents/modality_detector.py
CHANGED
|
@@ -1,13 +1,15 @@
|
|
| 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 |
-
Key fix:
|
| 8 |
-
|
|
|
|
| 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 |
-
# ββ
|
| 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
|
| 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
|
| 118 |
-
# bimodal ratio, large
|
| 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 |
-
|
| 124 |
-
center_region = sharpness[
|
| 125 |
edge_region = np.concatenate([
|
| 126 |
-
sharpness[:
|
| 127 |
-
sharpness[3*
|
| 128 |
-
sharpness[:, :
|
| 129 |
-
sharpness[:, 3*
|
| 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
|
| 145 |
-
|
| 146 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
if has_detail and bimodal_ratio > 1.5:
|
| 148 |
-
macro_score += 0.
|
|
|
|
|
|
|
| 149 |
if has_detail and bg_color_std < 40:
|
| 150 |
-
macro_score += 0.15
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
|
|
|
|
|
|
| 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
|
| 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.
|
| 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,
|