File size: 12,682 Bytes
09803bf b7d09ad 09803bf b7d09ad 09803bf b7d09ad 09803bf b7d09ad 09803bf 4f3dfe5 09803bf b7d09ad 09803bf 4f3dfe5 09803bf 4f3dfe5 09803bf 4f3dfe5 09803bf 7690770 09803bf 4f3dfe5 09803bf 4f3dfe5 09803bf 57eeb27 4f3dfe5 57eeb27 4f3dfe5 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 | """FORENSIQ β Sensor Characteristics Agent (18 features)"""
import numpy as np
from PIL import Image
from scipy.ndimage import gaussian_filter, uniform_filter, median_filter, label
from scipy.signal import convolve2d
from typing import Dict, Any
from agents.optical_agent import AgentEvidence
def _g(img): return np.array(img.convert("L")).astype(np.float64)
def _rgb(img): return np.array(img.convert("RGB")).astype(np.float64)
def s01_prnu_uniformity(img):
rgb=_rgb(img); res=[]
for c in range(3):
ch=rgb[:,:,c]; dn=gaussian_filter(ch,3.0); res.append(ch-dn)
ne=np.mean(np.stack(res,axis=-1)**2,axis=-1)
lv=uniform_filter(ne,32); std=float(np.std(lv)); mn=float(np.mean(lv))
u=1.0-min(std/(mn+1e-9),1.0)
if u>0.7: s,n=-0.4, f"Spatially consistent noise pattern (uniformity={u:.3f}) β authentic sensor"
elif u<0.4: s,n=0.5, f"Inconsistent noise ({u:.3f}) β splicing/AI"
else: s,n=0.1, f"Moderate noise uniformity ({u:.3f})"
return {"test":"PRNU Uniformity","uniformity":round(u,4),"score":s,"note":n,"noise_map":ne}
def s02_prnu_correlation(img):
rgb=_rgb(img); res=[]
for c in range(3): ch=rgb[:,:,c]; res.append((ch-gaussian_filter(ch,3.0)).ravel())
step=max(1,len(res[0])//100000)
rg=float(np.corrcoef(res[0][::step],res[1][::step])[0,1])
rb=float(np.corrcoef(res[0][::step],res[2][::step])[0,1])
avg=(rg+rb)/2
if avg>0.3: s,n=-0.3, f"Correlated channel noise ({avg:.3f}) β real sensor"
elif avg<0.1: s,n=0.4, f"Uncorrelated noise ({avg:.3f}) β AI-like"
else: s,n=0.1, f"Moderate noise correlation ({avg:.3f})"
return {"test":"PRNU Cross-Channel","correlation":round(avg,4),"score":s,"note":n}
def s03_noise_model(img):
rgb=_rgb(img); gray=np.mean(rgb,axis=-1); h,w=gray.shape; bs=16
hc,wc=(h//bs)*bs,(w//bs)*bs; gray=gray[:hc,:wc]
I,V=[],[]
for i in range(0,hc,bs):
for j in range(0,wc,bs):
b=gray[i:i+bs,j:j+bs]; I.append(float(np.mean(b))); V.append(float(np.var(b)))
I,V=np.array(I),np.array(V); v=(I>10)&(I<245)&(V>0)
if np.sum(v)<20: return {"test":"Poisson-Gaussian Model","score":0.0,"note":"Insufficient data"}
try:
c=np.polyfit(I[v],V[v],1); f=np.polyval(c,I[v]); r2=1.0-float(np.mean((V[v]-f)**2))/(np.var(V[v])+1e-9)
except: r2=0.0
if r2>0.5: s,n=-0.3, f"Poisson-Gaussian fit RΒ²={r2:.3f}"
elif r2<0.1: s,n=0.5, f"No sensor noise model RΒ²={r2:.3f}"
else: s,n=0.15, f"Weak fit RΒ²={r2:.3f}"
return {"test":"Poisson-Gaussian Model","r_squared":round(r2,4),"score":s,"note":n}
def s04_bayer(img):
rgb=_rgb(img); ns={}
for c,nm in enumerate(["red","green","blue"]):
ch=rgb[:,:,c]; ns[nm]=float(np.std(ch-gaussian_filter(ch,1.5)))
gl=ns["green"]<min(ns["red"],ns["blue"])
rb=abs(ns["red"]-ns["blue"])/(max(ns["red"],ns["blue"])+1e-9)<0.2
if gl and rb: s,n=-0.4, f"Bayer: ΟG({ns['green']:.3f})<ΟR({ns['red']:.3f})βΟB({ns['blue']:.3f})"
elif gl: s,n=-0.2, "Green quieter but R/B differ"
else: s,n=0.4, f"No Bayer pattern: ΟG={ns['green']:.3f}"
return {"test":"Bayer CFA Pattern","score":s,"note":n}
def s05_cfa_nyquist(img):
rgb=_rgb(img); rg=rgb[:,:,0]-rgb[:,:,1]; fft=np.abs(np.fft.fftshift(np.fft.fft2(rg)))
h,w=fft.shape; cy,cx=h//2,w//2
nyq=float(fft[cy,0]+fft[0,cx]+fft[0,0])/3; cen=float(np.mean(fft[cy-5:cy+5,cx-5:cx+5]))
r=nyq/(cen+1e-9)
if r>1.5: s,n=-0.3, f"CFA traces (ratio={r:.2f})"
elif r<0.5: s,n=0.3, f"No CFA traces ({r:.2f})"
else: s,n=0.0, f"CFA ratio={r:.2f}"
return {"test":"CFA Nyquist","ratio":round(r,4),"score":s,"note":n}
def s06_hot_dead(img):
gray=_g(img); h,w=gray.shape; med=median_filter(gray,5); diff=np.abs(gray-med)
hot=int(np.sum(diff>np.percentile(diff,99.9)))
dead=int(np.sum((gray<5)&(diff>np.percentile(diff,99.5))))
rate=(hot+dead)/(h*w)
# Note: JPEG compression removes hot pixel signatures, so absence is not strong evidence
is_jpeg = img.format == "JPEG" if hasattr(img, 'format') and img.format else False
if 0.00001<rate<0.001: s,n=-0.2, f"Sensor defects ({hot+dead}px, rate={rate:.6f})"
elif rate<0.000001 and not is_jpeg: s,n=0.15, f"No defects, non-JPEG β mild AI indicator"
elif rate<0.000001 and is_jpeg: s,n=0.0, f"No defects (JPEG strips hot pixels β inconclusive)"
else: s,n=0.0, f"Defect rate={rate:.6f}"
return {"test":"Hot/Dead Pixels","count":hot+dead,"score":s,"note":n}
def s07_fixed_pattern(img):
rgb=_rgb(img); gray=np.mean(rgb,axis=-1); h,w=gray.shape
row_means=np.mean(gray,axis=1); col_means=np.mean(gray,axis=0)
row_var=float(np.var(row_means-gaussian_filter(row_means,10)))
col_var=float(np.var(col_means-gaussian_filter(col_means,10)))
fpn=row_var+col_var
if fpn>5: s,n=-0.2, f"Fixed pattern noise ({fpn:.2f}) β sensor"
elif fpn<0.5: s,n=0.2, f"No fixed pattern ({fpn:.2f})"
else: s,n=0.0, f"FPN={fpn:.2f}"
return {"test":"Fixed Pattern Noise","fpn":round(fpn,4),"score":s,"note":n}
def s08_dark_current(img):
gray=_g(img); dark=gray[gray<10]
if len(dark)<100: return {"test":"Dark Current","score":0.0,"note":"No dark pixels"}
dk_mean=float(np.mean(dark)); dk_std=float(np.std(dark))
if dk_std>1: s,n=-0.2, f"Dark current variation (Ο={dk_std:.2f}) β sensor"
elif dk_std<0.3: s,n=0.1, f"Flat dark pixels (Ο={dk_std:.2f})"
else: s,n=0.0, f"Dark Ο={dk_std:.2f}"
return {"test":"Dark Current","dark_std":round(dk_std,3),"score":s,"note":n}
def s09_read_noise(img):
rgb=_rgb(img); gray=np.mean(rgb,axis=-1); h,w=gray.shape
# Find flat regions in 2D (preserve spatial structure)
flat_mask = (gray > 100) & (gray < 150)
if np.sum(flat_mask) < 1000: return {"test":"Read Noise Floor","score":0.0,"note":"No flat regions"}
# Compute noise as std of (original - smoothed) within flat regions only
smoothed = gaussian_filter(gray, sigma=2.0)
noise_residual = gray - smoothed
rn = float(np.std(noise_residual[flat_mask]))
if 0.5<rn<5: s,n=-0.2, f"Read noise={rn:.2f} β real sensor"
elif rn<0.2: s,n=0.3, f"No read noise ({rn:.2f})"
else: s,n=0.0, f"Read noise={rn:.2f}"
return {"test":"Read Noise Floor","read_noise":round(rn,3),"score":s,"note":n}
def s10_pixel_nonlinearity(img):
gray=_g(img); bins=np.linspace(0,255,20)
hist,_=np.histogram(gray,bins=bins); hist=hist.astype(float)
# Check for gaps/non-linearities in tonal response
smooth=gaussian_filter(hist.astype(np.float64),2); diff=np.abs(hist-smooth)
nonlin=float(np.mean(diff)/(np.mean(hist)+1e-9))
if nonlin<0.1: s,n=-0.2, f"Smooth tonal response ({nonlin:.3f})"
elif nonlin>0.3: s,n=0.3, f"Non-linear tonality ({nonlin:.3f})"
else: s,n=0.0, f"Tonal linearity={nonlin:.3f}"
return {"test":"Pixel Response Linearity","nonlinearity":round(nonlin,4),"score":s,"note":n}
def s11_color_matrix(img):
rgb=_rgb(img)
rg=float(np.corrcoef(rgb[:,:,0].ravel()[::100],rgb[:,:,1].ravel()[::100])[0,1])
rb=float(np.corrcoef(rgb[:,:,0].ravel()[::100],rgb[:,:,2].ravel()[::100])[0,1])
gb=float(np.corrcoef(rgb[:,:,1].ravel()[::100],rgb[:,:,2].ravel()[::100])[0,1])
avg=(rg+rb+gb)/3
if 0.5<avg<0.95: s,n=-0.2, f"Natural color matrix (avg_corr={avg:.3f})"
elif avg>0.98: s,n=0.2, f"Identical channels ({avg:.3f})"
else: s,n=0.0, f"Color correlation={avg:.3f}"
return {"test":"Color Matrix Verify","avg_corr":round(avg,4),"score":s,"note":n}
def s12_quantization(img):
try:
qt=img.quantization
if qt:
t=list(list(qt.values())[0].values()) if isinstance(list(qt.values())[0],dict) else list(list(qt.values())[0])
if len(t)==64:
mx,mn=int(np.max(t)),int(np.min(t)); std=float(np.std(t))
if std<5: s,n=0.2, f"Uniform quantization (std={std:.1f})"
elif mx>100: s,n=-0.2, f"Camera quantization (max={mx})"
else: s,n=-0.1, f"Standard JPEG table (range=[{mn},{mx}])"
else: s,n=0.0, "Non-standard table"
else: s,n=0.1, "No JPEG tables"
except: s,n=0.0, "Cannot read tables"
return {"test":"JPEG Quantization","score":s,"note":n}
def s13_bit_depth(img):
gray=_g(img); unique=len(np.unique(gray.astype(int)))
ratio=unique/256
if ratio>0.95: s,n=-0.2, f"Full 8-bit usage ({unique} levels)"
elif ratio<0.5: s,n=0.3, f"Limited tonal range ({unique} levels)"
else: s,n=0.0, f"{unique} unique levels"
return {"test":"Bit Depth Usage","unique_levels":unique,"score":s,"note":n}
def s14_saturation_clipping(img):
gray=_g(img); clip_white=float(np.mean(gray>254)); clip_black=float(np.mean(gray<1))
total=clip_white+clip_black
if 0.001<total<0.05: s,n=-0.2, f"Natural clipping ({total:.3%})"
elif total<0.0001: s,n=0.2, f"No clipping ({total:.5%}) β unusual"
elif total>0.1: s,n=0.1, f"Heavy clipping ({total:.1%})"
else: s,n=0.0, f"Clipping={total:.3%}"
return {"test":"Saturation Clipping","clip_fraction":round(total,5),"score":s,"note":n}
def s15_noise_spatial_freq(img):
rgb=_rgb(img); gray=np.mean(rgb,axis=-1)
noise=gray-gaussian_filter(gray,2)
fft=np.abs(np.fft.fftshift(np.fft.fft2(noise))); h,w=fft.shape; cy,cx=h//2,w//2
lf=float(np.mean(fft[cy-h//8:cy+h//8,cx-w//8:cx+w//8]))
hf=float(np.mean(fft))-lf
ratio=hf/(lf+1e-9)
if ratio>1.5: s,n=-0.2, f"High-freq noise dominant ({ratio:.2f}) β sensor"
elif ratio<0.5: s,n=0.3, f"Low-freq noise ({ratio:.2f}) β unusual"
else: s,n=0.0, f"Noise freq ratio={ratio:.2f}"
return {"test":"Noise Spatial Frequency","ratio":round(ratio,3),"score":s,"note":n}
def s16_green_imbalance(img):
rgb=_rgb(img); g=rgb[:,:,1]; h,w=g.shape
g1=g[0::2,0::2]; g2=g[1::2,1::2]
mh,mw=min(g1.shape[0],g2.shape[0]),min(g1.shape[1],g2.shape[1])
diff=float(np.mean(np.abs(g1[:mh,:mw]-g2[:mh,:mw])))
if diff>0.5: s,n=-0.2, f"Green channel imbalance ({diff:.3f}) β Bayer"
elif diff<0.1: s,n=0.2, f"Identical green subpixels ({diff:.3f})"
else: s,n=0.0, f"Green diff={diff:.3f}"
return {"test":"Green Pixel Imbalance","diff":round(diff,4),"score":s,"note":n}
def s17_noise_autocorrelation(img):
"""Real sensor noise has spatial correlation from the anti-aliasing filter.
AI noise is typically white (uncorrelated) or has non-physical correlation."""
gray=_g(img); noise=gray-gaussian_filter(gray,2.0)
h,w=noise.shape
if h<20 or w<20: return {"test":"Noise Autocorrelation","score":0.0,"note":"Too small"}
step=max(1,h*w//200000)
ac1=float(np.corrcoef(noise[:,:-1].ravel()[::step],noise[:,1:].ravel()[::step])[0,1])
ac2=float(np.corrcoef(noise[:,:-2].ravel()[::step],noise[:,2:].ravel()[::step])[0,1])
decay=ac1-ac2
if ac1>0.05 and decay>0.02: s,n=-0.3, f"AA-filtered noise (ac1={ac1:.3f}, decay={decay:.3f}) β real sensor"
elif ac1<0.01: s,n=0.2, f"White noise (ac1={ac1:.3f}) β no AA filter"
else: s,n=0.0, f"Noise ac1={ac1:.3f}, decay={decay:.3f}"
return {"test":"Noise Autocorrelation","ac1":round(ac1,4),"decay":round(decay,4),"score":s,"note":n}
def s18_demosaic_interpolation(img):
rgb=_rgb(img); h,w,_=rgb.shape
# Check for demosaic interpolation artifacts at pixel level
r=rgb[:,:,0]; g=rgb[:,:,1]; b=rgb[:,:,2]
# Real demosaiced images: neighboring pixels in same channel are correlated
r_h_corr = float(np.corrcoef(r[:,:-1].ravel()[::100],r[:,1:].ravel()[::100])[0,1])
g_h_corr = float(np.corrcoef(g[:,:-1].ravel()[::100],g[:,1:].ravel()[::100])[0,1])
# In Bayer, green has higher correlation due to 2x sampling
if g_h_corr > r_h_corr + 0.005: s,n = -0.3, f"Demosaic pattern (G_corr={g_h_corr:.4f}>R_corr={r_h_corr:.4f})"
elif abs(g_h_corr-r_h_corr)<0.001: s,n = 0.2, f"No demosaic signature"
else: s,n = 0.0, f"G_corr={g_h_corr:.4f}, R_corr={r_h_corr:.4f}"
return {"test":"Demosaic Interpolation","g_corr":round(g_h_corr,4),"r_corr":round(r_h_corr,4),"score":s,"note":n}
ALL_TESTS=[s01_prnu_uniformity,s02_prnu_correlation,s03_noise_model,s04_bayer,s05_cfa_nyquist,
s06_hot_dead,s07_fixed_pattern,s08_dark_current,s09_read_noise,s10_pixel_nonlinearity,
s11_color_matrix,s12_quantization,s13_bit_depth,s14_saturation_clipping,
s15_noise_spatial_freq,s16_green_imbalance,s17_noise_autocorrelation,s18_demosaic_interpolation]
def run_sensor_agent(img, modality_adjustments=None):
from agents.utils import run_agent_tests
findings, avg, conf, fail, rat = run_agent_tests(ALL_TESTS, img, "Sensor Characteristics Agent", modality_adjustments)
return AgentEvidence("Sensor Characteristics Agent",np.clip(avg,-1,1),conf,fail,rat,findings)
|