v5: Add minimum Bayer margin (0.005) to prevent JPEG-induced false Bayer detection on AI images
Browse files- agents/modality_detector.py +50 -104
agents/modality_detector.py
CHANGED
|
@@ -1,12 +1,10 @@
|
|
| 1 |
"""
|
| 2 |
-
FORENSIQ β Capture Modality Detector
|
| 3 |
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 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
|
| 53 |
|
| 54 |
-
# ββ Sharpness analysis
|
| 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:
|
| 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
|
| 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
|
| 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
|
| 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
|
| 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
|
| 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}
|
| 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 |
-
"
|
| 328 |
-
"
|
| 329 |
-
"
|
| 330 |
-
"
|
| 331 |
-
"
|
| 332 |
-
"
|
| 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 |
-
"
|
| 349 |
-
"
|
| 350 |
-
"
|
| 351 |
-
"
|
| 352 |
-
"
|
| 353 |
-
"
|
| 354 |
-
"
|
| 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 |
-
"
|
| 368 |
-
"
|
| 369 |
-
"
|
| 370 |
-
"
|
| 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 |
-
"
|
| 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:
|