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)