File size: 15,985 Bytes
23e2106
ceda018
 
 
23e2106
ceda018
 
 
23e2106
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bb7ae15
d348aa2
 
 
 
 
 
 
 
bb7ae15
d348aa2
bb7ae15
d348aa2
bb7ae15
d348aa2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bb7ae15
d348aa2
bb7ae15
d348aa2
 
 
 
 
 
 
 
bb7ae15
 
 
 
 
 
 
d348aa2
bb7ae15
f2fc256
23e2106
 
bb7ae15
23e2106
 
 
 
 
 
 
 
 
7acadc5
 
23e2106
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7acadc5
23e2106
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4529fa9
7acadc5
 
4529fa9
7acadc5
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
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
"""FORENSIQ β€” Generative Model Agent (15 features)"""
import numpy as np
from PIL import Image
from scipy.signal import find_peaks
from scipy.ndimage import gaussian_filter, label
from typing import Dict, Any
from agents.optical_agent import AgentEvidence

def _g(img): return np.array(img.convert("L")).astype(np.float64)

def m01_fft_grid_8x8(img):
    gray=_g(img); h,w=gray.shape; fft=np.fft.fftshift(np.fft.fft2(gray)); mag=np.log(np.abs(fft)+1)
    cy,cx=h//2,w//2; rp=mag[cy,:]; cp=mag[:,cx]
    rpeaks,_=find_peaks(rp,distance=5,prominence=0.3); cpeaks,_=find_peaks(cp,distance=5,prominence=0.3)
    def check(peaks,sz):
        if len(peaks)<3: return 0.0
        sp=np.diff(sorted(peaks)); e8=sz/8; e16=sz/16
        m8=np.sum(np.abs(sp-e8)<e8*0.15); m16=np.sum(np.abs(sp-e16)<e16*0.15)
        return float(max(m8,m16)/max(len(sp),1))
    gs=(check(rpeaks,w)+check(cpeaks,h))/2
    if gs>0.4: s,n=0.7,f"8Γ—8 grid artifacts (periodicity={gs:.2f})"
    elif gs>0.2: s,n=0.3,f"Weak grid patterns ({gs:.2f})"
    else: s,n=-0.2,"No grid artifacts"
    return {"test":"FFT Grid 8Γ—8","periodicity":round(gs,4),"score":s,"note":n,"magnitude_spectrum":mag}

def m02_fft_grid_16x16(img):
    gray=_g(img); h,w=gray.shape; fft=np.fft.fftshift(np.fft.fft2(gray)); mag=np.log(np.abs(fft)+1)
    cy,cx=h//2,w//2
    # Check specifically for 16Γ—16 period
    e16_h,e16_w=h//16,w//16
    peaks_h=[mag[cy,cx+k*e16_w] if cx+k*e16_w<w else 0 for k in range(1,8)]
    peaks_v=[mag[cy+k*e16_h,cx] if cy+k*e16_h<h else 0 for k in range(1,8)]
    avg_peak=(float(np.mean(peaks_h))+float(np.mean(peaks_v)))/2
    bg=float(np.median(mag))
    ratio=avg_peak/(bg+1e-9)
    if ratio>1.5: s,n=0.5,f"16Γ—16 spectral peaks (ratio={ratio:.2f})"
    elif ratio>1.2: s,n=0.2,f"Mild 16Γ—16 peaks ({ratio:.2f})"
    else: s,n=-0.1,f"No 16Γ—16 artifacts ({ratio:.2f})"
    return {"test":"FFT Grid 16Γ—16","peak_ratio":round(ratio,3),"score":s,"note":n}

def m03_spectral_slope(img):
    gray=_g(img); h,w=gray.shape; fft=np.fft.fftshift(np.fft.fft2(gray)); power=np.abs(fft)**2
    cy,cx=h//2,w//2; Y,X=np.mgrid[0:h,0:w]; R=np.sqrt((X-cx)**2+(Y-cy)**2).astype(int)
    maxr=min(cy,cx); rp=np.zeros(maxr)
    for r in range(maxr):
        m=R==r
        if m.any(): rp[r]=float(np.mean(power[m]))
    rp=np.log(rp+1); freqs=np.arange(1,maxr); lf=np.log(freqs+1); lp=rp[1:maxr]
    if len(lf)>10:
        c=np.polyfit(lf,lp,1); slope=float(c[0]); dev=abs(slope-(-2.0))
    else: slope=0; dev=2
    if dev<0.5: s,n=-0.3,f"Natural 1/fΒ² slope ({slope:.2f})"
    elif dev>1.5: s,n=0.3,f"Unnatural spectral slope ({slope:.2f})"
    else: s,n=0.1,f"Slope deviation={dev:.2f}"
    return {"test":"Spectral Slope 1/fΒ²","slope":round(slope,3),"score":s,"note":n}

def m04_diffusion_notches(img):
    gray=_g(img); fft=np.fft.fftshift(np.fft.fft2(gray)); power=np.abs(fft)**2
    h,w=power.shape; cy,cx=h//2,w//2; Y,X=np.mgrid[0:h,0:w]; R=np.sqrt((X-cx)**2+(Y-cy)**2).astype(int)
    maxr=min(cy,cx); rp=np.zeros(maxr)
    for r in range(maxr):
        m=R==r
        if m.any(): rp[r]=float(np.mean(power[m]))
    rp=np.log(rp+1)
    if len(rp)>20:
        c=np.polyfit(np.log(np.arange(1,maxr)+1),rp[1:maxr],1); fitted=np.polyval(c,np.log(np.arange(1,maxr)+1))
        res=rp[1:maxr]-fitted; notches,_=find_peaks(-res,prominence=0.5); nn=len(notches)
    else: nn=0
    if nn>3: s,n=0.5,f"{nn} spectral notches β€” diffusion signature"
    elif nn>1: s,n=0.2,f"{nn} notches"
    else: s,n=-0.1,"No diffusion notches"
    return {"test":"Diffusion Notches","count":nn,"score":s,"note":n}

def m05_autocorrelation(img):
    gray=_g(img); fft=np.fft.fft2(gray); power=np.abs(fft)**2
    ac=np.real(np.fft.ifft2(power)); ac=np.fft.fftshift(ac); ac=ac/(ac.max()+1e-9)
    h,w=ac.shape; cy,cx=h//2,w//2; acm=ac.copy()
    re=max(h,w)//20; Y,X=np.mgrid[0:h,0:w]; cm=((X-cx)**2+(Y-cy)**2)<re**2; acm[cm]=0
    ms=float(np.max(acm))
    
    # Before firing hard override, check if high autocorrelation is explained by bokeh.
    # Macro/portrait/shallow-DoF photos have large uniform bokeh regions that create
    # high autocorrelation from optical physics, not AI generation.
    #
    # Method: Local variance via uniform_filter. Bokeh regions have very low local 
    # variance (uniform pixel intensity). This is more robust than the Laplacian 
    # approach because it directly measures what bokeh is β€” spatial uniformity β€”
    # rather than inferring it from edge magnitude.
    bokeh_explained = False
    bokeh_fraction = 0.0
    if ms > 0.95:
        from scipy.ndimage import uniform_filter as uf
        
        # Compute local variance: E[XΒ²] - E[X]Β² over 32Γ—32 windows
        local_mean = uf(gray, size=32)
        local_sq_mean = uf(gray**2, size=32)
        local_var = np.clip(local_sq_mean - local_mean**2, 0, None)
        
        # Bimodal variance test: in a real bokeh/macro/shallow-DoF image, the 
        # variance distribution is strongly bimodal β€” sharp foreground has high 
        # local variance while the defocused background has near-zero variance.
        #
        # Strategy: Use the P95 of local variance (captures the sharp subject's
        # typical variance) and threshold at 5% of that value. Pixels below this
        # threshold are effectively uniform/bokeh. This works because:
        # - Real bokeh: P95 is high (sharp subject), most pixels are far below it
        # - AI smooth: P95 is low, so threshold is tiny, nothing qualifies as 
        #   "bokeh relative to sharp detail" because there IS no sharp detail
        # - Normal photo: variance is spread out, few pixels are <5% of P95
        p95_var = float(np.percentile(local_var, 95))
        
        if p95_var > 50.0:  # Guard: sharp region must have real detail
            var_thresh = p95_var * 0.05  # 5% of peak variance
            low_var_mask = local_var < var_thresh
            bokeh_fraction = float(np.mean(low_var_mask))
            has_genuine_detail = True  # Already guaranteed by p95 > 50 check
        else:
            # No region with genuine high-variance detail β€” not a bokeh image
            bokeh_fraction = 0.0
            low_var_mask = np.zeros_like(local_var, dtype=bool)
            has_genuine_detail = False
        
        # Bokeh explains autocorrelation if:
        # 1. Large uniform region (>35% of image has variance < 5% of P95)
        # 2. AND genuine sharp detail exists (P95 variance > 50)
        if bokeh_fraction > 0.35 and has_genuine_detail:
            bokeh_explained = True
    
    if ms > 0.95 and not bokeh_explained:
        s, n = 0.8, f"CRITICAL: Autocorrelation {ms:.3f} exceeds physical camera limit β€” AI generated"
        result = {"test":"Autocorrelation Peak","max_secondary":round(ms,4),"score":s,"note":n}
        result["override_suppression"] = True
        return result
    elif ms > 0.95 and bokeh_explained:
        s, n = 0.15, f"High autocorrelation ({ms:.3f}) but large bokeh region ({bokeh_fraction:.0%} uniform background) β€” likely optical, not AI"
        return {"test":"Autocorrelation Peak","max_secondary":round(ms,4),"score":s,"note":n,"bokeh_explained":True}
    elif ms>0.3: s,n=0.6,f"Strong secondary peak ({ms:.3f}) β€” GAN checkerboard"
    elif ms>0.15: s,n=0.3,f"Moderate peak ({ms:.3f})"
    else: s,n=-0.2,f"Natural autocorrelation ({ms:.3f})"
    return {"test":"Autocorrelation Peak","max_secondary":round(ms,4),"score":s,"note":n}

def m06_checkerboard(img):
    gray=_g(img); h,w=gray.shape
    if h<10 or w<10: return {"test":"Checkerboard Pattern","score":0.0,"note":"Too small"}
    ha=float(np.corrcoef(gray[:,:-2].ravel()[::100],gray[:,2:].ravel()[::100])[0,1])
    va=float(np.corrcoef(gray[:-2,:].ravel()[::100],gray[2:,:].ravel()[::100])[0,1])
    h1=float(np.corrcoef(gray[:,:-1].ravel()[::100],gray[:,1:].ravel()[::100])[0,1])
    v1=float(np.corrcoef(gray[:-1,:].ravel()[::100],gray[1:,:].ravel()[::100])[0,1])
    delta=((ha-h1)+(va-v1))/2
    if delta>0.1: s,n=0.5,f"Strong checkerboard (Ξ”={delta:.4f})"
    elif delta>0.05: s,n=0.25,f"Mild checkerboard (Ξ”={delta:.4f})"
    else: s,n=-0.1,f"No checkerboard ({delta:.4f})"
    return {"test":"Checkerboard Pattern","delta":round(delta,6),"score":s,"note":n}

def m07_vae_boundaries(img):
    gray=_g(img); h,w=gray.shape; best_r,best_s=1.0,0
    for ps in [32,64,128]:
        if h<ps*2 or w<ps*2: continue
        hc,wc=(h//ps)*ps,(w//ps)*ps; g=gray[:hc,:wc]
        bd,it=[],[]
        for i in range(1,hc):
            rd=np.abs(g[i,:]-g[i-1,:])
            if i%ps==0: bd.append(float(np.mean(rd)))
            elif i%ps!=1: it.append(float(np.mean(rd)))
        if bd and it:
            r=float(np.mean(bd))/(float(np.mean(it))+1e-9)
            if r>best_r: best_r=r; best_s=ps
    if best_r>1.3: s,n=0.4,f"VAE boundaries at {best_s}px ({best_r:.3f})"
    elif best_r>1.1: s,n=0.2,f"Weak boundaries ({best_r:.3f})"
    else: s,n=-0.1,f"No VAE boundaries ({best_r:.3f})"
    return {"test":"VAE Patch Boundaries","ratio":round(best_r,4),"score":s,"note":n}

def m08_spectral_symmetry(img):
    gray=_g(img); fft=np.fft.fftshift(np.fft.fft2(gray)); mag=np.log(np.abs(fft)+1)
    h,w=mag.shape; cy,cx=h//2,w//2
    top=mag[:cy,:]; bot=np.flipud(mag[cy+1:,:])
    left=mag[:,:cx]; right=np.fliplr(mag[:,cx+1:])
    mh,mw=min(top.shape[0],bot.shape[0]),min(top.shape[1],bot.shape[1])
    asym_tb=float(np.mean(np.abs(top[:mh,:mw]-bot[:mh,:mw])))
    mh2,mw2=min(left.shape[0],right.shape[0]),min(left.shape[1],right.shape[1])
    asym_lr=float(np.mean(np.abs(left[:mh2,:mw2]-right[:mh2,:mw2])))
    asym=(asym_tb+asym_lr)/2
    if asym<0.1: s,n=-0.1,f"Symmetric spectrum ({asym:.3f})"
    elif asym>0.5: s,n=0.3,f"Asymmetric spectrum ({asym:.3f})"
    else: s,n=0.0,f"Spectral asymmetry={asym:.3f}"
    return {"test":"Spectral Symmetry","asymmetry":round(asym,4),"score":s,"note":n}

def m09_upsampling_stride(img):
    gray=_g(img); h,w=gray.shape
    # Check for stride-2 upsampling artifacts
    even=gray[::2,::2]; odd=gray[1::2,1::2]
    mh,mw=min(even.shape[0],odd.shape[0]),min(even.shape[1],odd.shape[1])
    diff=float(np.mean(np.abs(even[:mh,:mw]-odd[:mh,:mw])))
    mean_val=float(np.mean(gray))
    norm_diff=diff/(mean_val+1e-9)
    if norm_diff>0.1: s,n=-0.1,f"Natural stride variation ({norm_diff:.4f})"
    elif norm_diff<0.01: s,n=0.3,f"Stride-2 artifacts ({norm_diff:.4f})"
    else: s,n=0.0,f"Stride diff={norm_diff:.4f}"
    return {"test":"Upsampling Stride-2","norm_diff":round(norm_diff,4),"score":s,"note":n}

def m10_patch_diversity(img):
    gray=_g(img); h,w=gray.shape; ps=32
    hc,wc=(h//ps)*ps,(w//ps)*ps; gray=gray[:hc,:wc]
    patches=[]
    for i in range(0,hc,ps):
        for j in range(0,wc,ps):
            patches.append(gray[i:i+ps,j:j+ps].ravel())
    if len(patches)<4: return {"test":"Patch Diversity","score":0.0,"note":"Too few patches"}
    patches=np.array(patches); means=np.mean(patches,axis=1); stds=np.std(patches,axis=1)
    diversity=float(np.std(stds)/(np.mean(stds)+1e-9))
    if diversity>0.5: s,n=-0.2,f"High patch diversity ({diversity:.3f}) β€” natural"
    elif diversity<0.15: s,n=0.3,f"Low patch diversity ({diversity:.3f}) β€” GAN mode collapse"
    else: s,n=0.0,f"Patch diversity={diversity:.3f}"
    return {"test":"Patch Diversity","diversity":round(diversity,4),"score":s,"note":n}

def m11_color_consistency(img):
    rgb=np.array(img.convert("RGB")).astype(np.float64); h,w,_=rgb.shape; ps=64
    hc,wc=(h//ps)*ps,(w//ps)*ps; rgb=rgb[:hc,:wc]
    ratios=[]
    for i in range(0,hc,ps):
        for j in range(0,wc,ps):
            p=rgb[i:i+ps,j:j+ps]; m=np.mean(p,axis=(0,1))
            if m[1]>30: ratios.append(m[0]/(m[1]+1e-9))  # min green=30 to avoid near-black patches
    if len(ratios)<4: return {"test":"Color Ratio Consistency","score":0.0,"note":"Few patches"}
    cv=float(np.std(ratios))/(float(np.mean(ratios))+1e-9)
    if cv>0.1: s,n=-0.2,f"Varied color ratios (CV={cv:.3f})"
    elif cv<0.02: s,n=0.2,f"Suspiciously uniform color ({cv:.3f})"
    else: s,n=0.0,f"Color CV={cv:.3f}"
    return {"test":"Color Ratio Consistency","cv":round(cv,4),"score":s,"note":n}

def m12_spectral_rolloff_shape(img):
    gray=_g(img); fft=np.abs(np.fft.fftshift(np.fft.fft2(gray)))
    h,w=fft.shape; cy,cx=h//2,w//2
    diag1=np.array([fft[cy+i,cx+i] for i in range(min(cy,cx)//2)])
    diag2=np.array([fft[cy+i,cx-i] for i in range(min(cy,cx)//2)])
    if len(diag1)>5:
        d1=np.log(diag1+1); d2=np.log(diag2+1)
        aniso=float(np.mean(np.abs(d1-d2)))/(float(np.mean(d1))+1e-9)
    else: aniso=0
    if aniso>0.1: s,n=-0.1,f"Anisotropic rolloff ({aniso:.3f})"
    elif aniso<0.02: s,n=0.2,f"Isotropic rolloff ({aniso:.3f}) β€” AI-like"
    else: s,n=0.0,f"Rolloff anisotropy={aniso:.3f}"
    return {"test":"Spectral Rolloff Shape","anisotropy":round(aniso,4),"score":s,"note":n}

def m13_texture_repetition(img):
    gray=_g(img); h,w=gray.shape; ps=64
    if h<ps*3 or w<ps*3: return {"test":"Texture Repetition","score":0.0,"note":"Too small"}
    hc,wc=(h//ps)*ps,(w//ps)*ps; gray=gray[:hc,:wc]
    patches=[]
    for i in range(0,hc,ps):
        for j in range(0,wc,ps):
            p=gray[i:i+ps,j:j+ps]; p=(p-np.mean(p))/(np.std(p)+1e-9)
            patches.append(p.ravel())
    if len(patches)<4: return {"test":"Texture Repetition","score":0.0,"note":"Few patches"}
    patches=np.array(patches)
    # Find max correlation between non-adjacent patches
    max_corr=0
    for i in range(min(len(patches),20)):
        for j in range(i+2,min(len(patches),20)):
            c=float(np.corrcoef(patches[i],patches[j])[0,1])
            if c>max_corr: max_corr=c
    if max_corr>0.8: s,n=0.4,f"Repeated textures ({max_corr:.3f}) β€” GAN copy"
    elif max_corr>0.5: s,n=0.2,f"Similar textures ({max_corr:.3f})"
    else: s,n=-0.1,f"Varied textures ({max_corr:.3f})"
    return {"test":"Texture Repetition","max_corr":round(max_corr,4),"score":s,"note":n}

def m14_highfreq_noise_structure(img):
    gray=_g(img); noise=gray-gaussian_filter(gray,1.0)
    fft=np.abs(np.fft.fftshift(np.fft.fft2(noise))); h,w=fft.shape; cy,cx=h//2,w//2
    # Radial power in HF noise
    Y,X=np.mgrid[0:h,0:w]; R=np.sqrt((X-cx)**2+(Y-cy)**2)
    Rm=min(cy,cx); hf=fft[R>Rm*0.5]; lf=fft[R<Rm*0.3]
    ratio=float(np.mean(hf))/(float(np.mean(lf))+1e-9)
    if ratio>2: s,n=-0.2,f"HF-dominant noise ({ratio:.2f}) β€” sensor"
    elif ratio<0.5: s,n=0.3,f"LF-dominant noise ({ratio:.2f}) β€” AI smoothing"
    else: s,n=0.0,f"Noise HF/LF={ratio:.2f}"
    return {"test":"HF Noise Structure","ratio":round(ratio,3),"score":s,"note":n}

def m15_phase_coherence(img):
    gray=_g(img); fft=np.fft.fft2(gray); phase=np.angle(fft)
    h,w=phase.shape
    # Natural images: smooth phase transitions
    ph_dx=np.abs(np.diff(phase,axis=1)); ph_dy=np.abs(np.diff(phase,axis=0))
    # Wrap-around correction
    ph_dx[ph_dx>np.pi]=2*np.pi-ph_dx[ph_dx>np.pi]
    ph_dy[ph_dy>np.pi]=2*np.pi-ph_dy[ph_dy>np.pi]
    smoothness=float(np.mean(ph_dx)+np.mean(ph_dy))
    if smoothness<2: s,n=-0.2,f"Coherent phase ({smoothness:.3f})"
    elif smoothness>2.5: s,n=0.2,f"Incoherent phase ({smoothness:.3f})"
    else: s,n=0.0,f"Phase coherence={smoothness:.3f}"
    return {"test":"Phase Coherence","smoothness":round(smoothness,4),"score":s,"note":n}

ALL_TESTS=[m01_fft_grid_8x8,m02_fft_grid_16x16,m03_spectral_slope,m04_diffusion_notches,
           m05_autocorrelation,m06_checkerboard,m07_vae_boundaries,m08_spectral_symmetry,
           m09_upsampling_stride,m10_patch_diversity,m11_color_consistency,m12_spectral_rolloff_shape,
           m13_texture_repetition,m14_highfreq_noise_structure,m15_phase_coherence]

def run_model_agent(img, modality_adjustments=None):
    from agents.utils import run_agent_tests
    from agents.optical_agent import AgentEvidence
    findings, avg, conf, fail, rat = run_agent_tests(ALL_TESTS, img, "Generative Model Agent", modality_adjustments)
    return AgentEvidence("Generative Model Agent",np.clip(avg,-1,1),conf,fail,rat,findings)