feat: overhaul screenshot detection + suppression + explanation text (v7)
Browse filesDetection improvements:
- Standard screen resolution matching (19 common resolutions including Retina)
- Low unique color count (<32 per channel β real UI renders use exact colors)
- H/V edge dominance with minimum edge strength gate
- Hard Bayer CFA exclusion (cameras can't be screenshots)
- 2-trait gate requiring at least 1 content-based trait (prevents false triggers)
- Safety guard exception: screenshots allowed through low-detail guard at score>=0.6
Suppression expansion (15 β 37 tests):
- All optical lens tests (no lens on screenshots)
- All sensor tests (no camera sensor)
- Diffusion Notches Γ0.15 (LCD pixel grid creates false spectral harmonics)
- Benford's Law Γ0.2, Color Histogram Γ0.2 (UI color distributions violate natural priors)
- DCT/Wavelet Kurtosis Γ0.3, Gradient Sparsity Γ0.3
- EXIF/Maker Note/Thumbnail/GPS Γ0.1 (screenshots have no camera metadata)
Explanation text:
- Screenshot-specific disclosure in forensic report explaining methodology limitations
- Screenshot-aware conclusion/recommendation in court brief
- Explicit note that photographic forensic tests don't apply to UI screenshots
- agents/modality_detector.py +100 -15
|
@@ -1,15 +1,18 @@
|
|
| 1 |
"""
|
| 2 |
-
FORENSIQ β Capture Modality Detector
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
v6: Added Vignetting cosβ΄ΞΈ, Bokeh Shape, and Fixed Pattern Noise Γ0.3 suppression
|
| 5 |
-
for MACRO_DSLR modality.
|
| 6 |
-
extremely smooth circular bokeh from long focal length, and low row/column variance
|
| 7 |
-
in the smooth background region.
|
| 8 |
|
| 9 |
v5: Added minimum bayer_margin threshold (0.005) to prevent JPEG re-encoding
|
| 10 |
-
from creating false Bayer CFA patterns on AI images.
|
| 11 |
-
bayer_margin > 0.01; AI images through JPEG have margin < 0.001.
|
| 12 |
-
Also: sharpness_ratio > 3.0 hard gate for macro detection.
|
| 13 |
"""
|
| 14 |
|
| 15 |
import sys
|
|
@@ -44,8 +47,6 @@ def detect_modality(img: Image.Image) -> ModalityResult:
|
|
| 44 |
rgb = np.array(img.convert("RGB")).astype(np.float64)
|
| 45 |
|
| 46 |
# βββ CRITICAL PRE-CHECK: Bayer CFA pattern βββββββββββββββββββββββ
|
| 47 |
-
# Real camera: Ο_green < Ο_red β Ο_blue (green has 2x sampling density)
|
| 48 |
-
# AI image through JPEG: all channels have ~same noise (margin β 0)
|
| 49 |
noise_std = {}
|
| 50 |
for c, nm in enumerate(["red", "green", "blue"]):
|
| 51 |
ch = rgb[:, :, c]
|
|
@@ -53,7 +54,6 @@ def detect_modality(img: Image.Image) -> ModalityResult:
|
|
| 53 |
noise_std[nm] = float(np.std(ch - dn))
|
| 54 |
|
| 55 |
bayer_margin = min(noise_std["red"], noise_std["blue"]) - noise_std["green"]
|
| 56 |
-
# Require BOTH: green < min(red, blue) AND margin above noise floor
|
| 57 |
has_bayer = (noise_std["green"] < min(noise_std["red"], noise_std["blue"])
|
| 58 |
and bayer_margin > MIN_BAYER_MARGIN)
|
| 59 |
|
|
@@ -83,7 +83,6 @@ def detect_modality(img: Image.Image) -> ModalityResult:
|
|
| 83 |
sharp_region = sharpness > sharp_thresh
|
| 84 |
sharp_frac = float(np.mean(sharp_region))
|
| 85 |
|
| 86 |
-
# Blur region: content-based threshold (20% of p95)
|
| 87 |
blur_content_thresh = p95 * 0.20
|
| 88 |
blur_region = sharpness < blur_content_thresh
|
| 89 |
blur_frac = float(np.mean(blur_region))
|
|
@@ -177,6 +176,11 @@ def detect_modality(img: Image.Image) -> ModalityResult:
|
|
| 177 |
indicators["macro_detected"] = True
|
| 178 |
|
| 179 |
# ββ Screenshot detection ββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
edge_mag = np.hypot(sobel(gray, 0), sobel(gray, 1))
|
| 181 |
strong = edge_mag > np.percentile(edge_mag, 95)
|
| 182 |
gx = sobel(gray, axis=1); gy = sobel(gray, axis=0)
|
|
@@ -184,11 +188,70 @@ def detect_modality(img: Image.Image) -> ModalityResult:
|
|
| 184 |
v_edges = np.abs(gy) > np.abs(gx) * 3
|
| 185 |
hv_ratio = float(np.sum(h_edges | v_edges)) / (float(np.sum(strong)) + 1e-9)
|
| 186 |
|
| 187 |
-
ratio
|
| 188 |
-
|
| 189 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 190 |
indicators["screenshot_detected"] = True
|
| 191 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
# ββ Double JPEG detection (messaging) βββββββββββββββββββββββββββββ
|
| 193 |
hc, wc = (gray.shape[0] // 8) * 8, (gray.shape[1] // 8) * 8
|
| 194 |
blockiness = 1.0
|
|
@@ -260,10 +323,17 @@ def detect_modality(img: Image.Image) -> ModalityResult:
|
|
| 260 |
conf = min(1.0, scores["PORTRAIT_MODE"])
|
| 261 |
|
| 262 |
# SAFETY GUARD 1: No detail = possible AI
|
| 263 |
-
|
|
|
|
|
|
|
| 264 |
modality = "UNKNOWN"
|
| 265 |
conf = 0.2
|
| 266 |
indicators["safety_override"] = "Low-detail image β suppression disabled"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
|
| 268 |
# SAFETY GUARD 2: No real Bayer = not a real camera
|
| 269 |
if not has_bayer and modality in ("PORTRAIT_MODE", "SMARTPHONE", "MESSAGING"):
|
|
@@ -338,12 +408,27 @@ def _get_modality_adjustments(modality: str) -> dict:
|
|
| 338 |
}
|
| 339 |
elif modality == "SCREENSHOT":
|
| 340 |
return {
|
|
|
|
| 341 |
"Vignetting cosβ΄ΞΈ": 0.1, "Vignetting Symmetry": 0.1,
|
| 342 |
"Lens Distortion": 0.1, "Field Curvature": 0.1,
|
| 343 |
"CA Magnitude": 0.1, "CA Radial Gradient": 0.1, "Lateral CA": 0.1,
|
| 344 |
"Purple Fringing": 0.1, "Bokeh Shape": 0.1,
|
|
|
|
|
|
|
|
|
|
| 345 |
"PRNU Uniformity": 0.1, "Bayer CFA Pattern": 0.1, "CFA Nyquist": 0.1,
|
| 346 |
"Hot/Dead Pixels": 0.1, "Noise Autocorrelation": 0.1, "Demosaic Interpolation": 0.1,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 347 |
}
|
| 348 |
elif modality == "SMARTPHONE":
|
| 349 |
return {
|
|
|
|
| 1 |
"""
|
| 2 |
+
FORENSIQ β Capture Modality Detector v7
|
| 3 |
+
|
| 4 |
+
v7: Overhauled SCREENSHOT detection β now detects OS screenshots (pixel-perfect,
|
| 5 |
+
standard resolution, few unique colors, no Bayer CFA, uniform sharpness) in
|
| 6 |
+
addition to photographed screens (H/V edge dominance). Expanded SCREENSHOT
|
| 7 |
+
suppression from 15 to 30+ tests covering lens physics, sensor characteristics,
|
| 8 |
+
LCD pixel grid frequency artifacts (Diffusion Notches), and UI color distribution
|
| 9 |
+
violations (Benford's Law, Color Histogram).
|
| 10 |
|
| 11 |
v6: Added Vignetting cosβ΄ΞΈ, Bokeh Shape, and Fixed Pattern Noise Γ0.3 suppression
|
| 12 |
+
for MACRO_DSLR modality.
|
|
|
|
|
|
|
| 13 |
|
| 14 |
v5: Added minimum bayer_margin threshold (0.005) to prevent JPEG re-encoding
|
| 15 |
+
from creating false Bayer CFA patterns on AI images.
|
|
|
|
|
|
|
| 16 |
"""
|
| 17 |
|
| 18 |
import sys
|
|
|
|
| 47 |
rgb = np.array(img.convert("RGB")).astype(np.float64)
|
| 48 |
|
| 49 |
# βββ CRITICAL PRE-CHECK: Bayer CFA pattern βββββββββββββββββββββββ
|
|
|
|
|
|
|
| 50 |
noise_std = {}
|
| 51 |
for c, nm in enumerate(["red", "green", "blue"]):
|
| 52 |
ch = rgb[:, :, c]
|
|
|
|
| 54 |
noise_std[nm] = float(np.std(ch - dn))
|
| 55 |
|
| 56 |
bayer_margin = min(noise_std["red"], noise_std["blue"]) - noise_std["green"]
|
|
|
|
| 57 |
has_bayer = (noise_std["green"] < min(noise_std["red"], noise_std["blue"])
|
| 58 |
and bayer_margin > MIN_BAYER_MARGIN)
|
| 59 |
|
|
|
|
| 83 |
sharp_region = sharpness > sharp_thresh
|
| 84 |
sharp_frac = float(np.mean(sharp_region))
|
| 85 |
|
|
|
|
| 86 |
blur_content_thresh = p95 * 0.20
|
| 87 |
blur_region = sharpness < blur_content_thresh
|
| 88 |
blur_frac = float(np.mean(blur_region))
|
|
|
|
| 176 |
indicators["macro_detected"] = True
|
| 177 |
|
| 178 |
# ββ Screenshot detection ββββββββββββββββββββββββββββββββββββββββββ
|
| 179 |
+
# Two types: (1) OS screenshot (PrintScreen/cmd+Shift) β pixel-perfect,
|
| 180 |
+
# no lens physics, exact resolution match, few unique colors
|
| 181 |
+
# (2) Photographed screen β has lens physics but also display grid artifacts
|
| 182 |
+
|
| 183 |
+
# H/V edge dominance (UI elements are rectilinear)
|
| 184 |
edge_mag = np.hypot(sobel(gray, 0), sobel(gray, 1))
|
| 185 |
strong = edge_mag > np.percentile(edge_mag, 95)
|
| 186 |
gx = sobel(gray, axis=1); gy = sobel(gray, axis=0)
|
|
|
|
| 188 |
v_edges = np.abs(gy) > np.abs(gx) * 3
|
| 189 |
hv_ratio = float(np.sum(h_edges | v_edges)) / (float(np.sum(strong)) + 1e-9)
|
| 190 |
|
| 191 |
+
# H/V ratio is only meaningful if edges are genuinely strong (not noise)
|
| 192 |
+
median_edge = float(np.median(edge_mag))
|
| 193 |
+
strong_edges_present = float(np.percentile(edge_mag, 95)) > max(5.0, median_edge * 3)
|
| 194 |
+
|
| 195 |
+
aspect = max(w, h) / (min(w, h) + 1e-9)
|
| 196 |
+
|
| 197 |
+
# Standard screen resolutions (and 2Γ Retina variants)
|
| 198 |
+
standard_res = {
|
| 199 |
+
(1920, 1080), (2560, 1440), (3840, 2160), (1366, 768), (1280, 720),
|
| 200 |
+
(1440, 900), (1680, 1050), (2560, 1600), (2880, 1800), (3024, 1964),
|
| 201 |
+
(1536, 864), (1600, 900), (3456, 2234), (2560, 1080), (3440, 1440),
|
| 202 |
+
(1280, 800), (1024, 768), (2048, 1536), (2732, 2048),
|
| 203 |
+
}
|
| 204 |
+
is_standard_res = (w, h) in standard_res or (h, w) in standard_res
|
| 205 |
+
|
| 206 |
+
# Count unique colors per channel β UI screenshots use few exact colors
|
| 207 |
+
unique_colors = [len(np.unique(rgb[:, :, c].astype(np.uint8))) for c in range(3)]
|
| 208 |
+
max_unique = max(unique_colors)
|
| 209 |
+
low_color_count = max_unique < 32 # Pure UI screenshots use <30 distinct values per channel
|
| 210 |
+
|
| 211 |
+
# Uniform sharpness β no blur gradient (unlike camera photos)
|
| 212 |
+
sharpness_cv = float(np.std(sharpness)) / (float(np.mean(sharpness)) + 1e-9)
|
| 213 |
+
uniform_sharpness = sharpness_cv < 0.5
|
| 214 |
+
|
| 215 |
+
# No Bayer CFA = not a camera sensor
|
| 216 |
+
no_bayer = not has_bayer
|
| 217 |
+
|
| 218 |
+
screenshot_score = 0.0
|
| 219 |
+
screenshot_traits = 0 # Count distinct UI traits for gating
|
| 220 |
+
|
| 221 |
+
# Hard gate: images with real Bayer CFA pattern are from cameras, not screenshots
|
| 222 |
+
if not has_bayer:
|
| 223 |
+
# OS screenshot path β pixel-perfect, no lens physics
|
| 224 |
+
if is_standard_res:
|
| 225 |
+
screenshot_score += 0.2
|
| 226 |
+
screenshot_traits += 1
|
| 227 |
+
indicators["standard_resolution"] = True
|
| 228 |
+
if low_color_count:
|
| 229 |
+
screenshot_score += 0.25
|
| 230 |
+
screenshot_traits += 1
|
| 231 |
+
indicators["low_color_count"] = max_unique
|
| 232 |
+
if hv_ratio > 0.6 and strong_edges_present:
|
| 233 |
+
screenshot_score += 0.25
|
| 234 |
+
screenshot_traits += 1
|
| 235 |
+
if uniform_sharpness:
|
| 236 |
+
screenshot_score += 0.15
|
| 237 |
+
if low_color_count:
|
| 238 |
+
screenshot_score += 0.1 # No sensor + few colors = UI render
|
| 239 |
+
|
| 240 |
+
# Gate: require at least 2 distinct UI traits AND at least one must be
|
| 241 |
+
# content-based (colors or edges), not just resolution match.
|
| 242 |
+
# This prevents smooth AI images at 1920Γ1080 from false-triggering.
|
| 243 |
+
has_content_trait = low_color_count or (hv_ratio > 0.6 and strong_edges_present)
|
| 244 |
+
if screenshot_traits < 2 or not has_content_trait:
|
| 245 |
+
screenshot_score = 0.0
|
| 246 |
+
|
| 247 |
+
if screenshot_score >= 0.4:
|
| 248 |
+
scores["SCREENSHOT"] = min(1.0, screenshot_score)
|
| 249 |
indicators["screenshot_detected"] = True
|
| 250 |
|
| 251 |
+
indicators["hv_ratio"] = round(hv_ratio, 3)
|
| 252 |
+
indicators["max_unique_colors"] = max_unique
|
| 253 |
+
indicators["sharpness_cv"] = round(sharpness_cv, 3)
|
| 254 |
+
|
| 255 |
# ββ Double JPEG detection (messaging) βββββββββββββββββββββββββββββ
|
| 256 |
hc, wc = (gray.shape[0] // 8) * 8, (gray.shape[1] // 8) * 8
|
| 257 |
blockiness = 1.0
|
|
|
|
| 323 |
conf = min(1.0, scores["PORTRAIT_MODE"])
|
| 324 |
|
| 325 |
# SAFETY GUARD 1: No detail = possible AI
|
| 326 |
+
# Exception: SCREENSHOT modality is legitimately low-detail (UI renders exact colors,
|
| 327 |
+
# not photographic texture). If screenshot signals are strong, let it through.
|
| 328 |
+
if not has_detail and modality != "SCREENSHOT":
|
| 329 |
modality = "UNKNOWN"
|
| 330 |
conf = 0.2
|
| 331 |
indicators["safety_override"] = "Low-detail image β suppression disabled"
|
| 332 |
+
elif not has_detail and modality == "SCREENSHOT" and scores.get("SCREENSHOT", 0) < 0.6:
|
| 333 |
+
# Weak screenshot signal + no detail β still override
|
| 334 |
+
modality = "UNKNOWN"
|
| 335 |
+
conf = 0.2
|
| 336 |
+
indicators["safety_override"] = "Low-detail, weak screenshot signal β suppression disabled"
|
| 337 |
|
| 338 |
# SAFETY GUARD 2: No real Bayer = not a real camera
|
| 339 |
if not has_bayer and modality in ("PORTRAIT_MODE", "SMARTPHONE", "MESSAGING"):
|
|
|
|
| 408 |
}
|
| 409 |
elif modality == "SCREENSHOT":
|
| 410 |
return {
|
| 411 |
+
# Optical physics β screenshots have no lens, suppress all lens tests
|
| 412 |
"Vignetting cosβ΄ΞΈ": 0.1, "Vignetting Symmetry": 0.1,
|
| 413 |
"Lens Distortion": 0.1, "Field Curvature": 0.1,
|
| 414 |
"CA Magnitude": 0.1, "CA Radial Gradient": 0.1, "Lateral CA": 0.1,
|
| 415 |
"Purple Fringing": 0.1, "Bokeh Shape": 0.1,
|
| 416 |
+
"Sharpness Falloff": 0.1, "DoF Consistency": 0.1, "DoF Gradient Direction": 0.1,
|
| 417 |
+
"Diffraction Limit": 0.1, "Optical Center": 0.1,
|
| 418 |
+
# Sensor β screenshots have no camera sensor
|
| 419 |
"PRNU Uniformity": 0.1, "Bayer CFA Pattern": 0.1, "CFA Nyquist": 0.1,
|
| 420 |
"Hot/Dead Pixels": 0.1, "Noise Autocorrelation": 0.1, "Demosaic Interpolation": 0.1,
|
| 421 |
+
"PRNU Cross-Channel": 0.1, "Poisson-Gaussian Model": 0.1,
|
| 422 |
+
"Fixed Pattern Noise": 0.1, "Green Pixel Imbalance": 0.1,
|
| 423 |
+
# Generative model β LCD pixel grid creates false frequency artifacts
|
| 424 |
+
"Diffusion Notches": 0.15, "FFT Grid 8Γ8": 0.3, "FFT Grid 16Γ16": 0.3,
|
| 425 |
+
# Statistical β UI color distributions violate natural-image priors
|
| 426 |
+
"Benford's Law": 0.2, "Color Histogram": 0.2,
|
| 427 |
+
"DCT Kurtosis": 0.3, "Wavelet Kurtosis": 0.3,
|
| 428 |
+
"Gradient Sparsity": 0.3,
|
| 429 |
+
# Metadata β screenshots lack camera EXIF by definition
|
| 430 |
+
"EXIF Completeness": 0.1, "Maker Note": 0.1, "Thumbnail Check": 0.1,
|
| 431 |
+
"GPS Plausibility": 0.1, "ICC Color Profile": 0.2,
|
| 432 |
}
|
| 433 |
elif modality == "SMARTPHONE":
|
| 434 |
return {
|