deepfake-forensics-api / backend /pipeline /frequency_analysis.py
warlord123456
Deploy to Hugging Face
7bbed67
Raw
History Blame Contribute Delete
37.5 kB
"""
Frequency Domain & Spectral Analysis for Deepfake Detection.
Deepfakes often leave artifacts in the frequency domain that are invisible
in the spatial domain. This module implements:
1. DCT (Discrete Cosine Transform) spectral analysis
2. FFT (Fast Fourier Transform) magnitude spectrum
3. Azimuthal averaging for frequency band energy comparison
4. Per-channel (R,G,B) spectral consistency analysis
5. PCA spectral decomposition for hidden artifact detection
These techniques exploit the fact that GAN-generated images often have
suppressed high-frequency components, periodic artifacts in the spectrum,
and channel-specific inconsistencies invisible to the human eye.
"""
import numpy as np
import cv2
import os
import pywt
def compute_swn_noise_map(image_rgb, save_path=None):
"""
Implements the Switching Noise Estimator (SWN) from Ranjbaran et al. (2015).
Detects high-frequency zero-crossings (pure noise) while using Gaussian weighting
to suppress actual physical edges. Highlights deepfake splicing and generation artifacts.
Enhanced with anomaly contours, original image overlay, and colorbar legend.
Returns:
final: Colorized visualization image with overlays.
anomaly_ratio: Ratio of significant anomaly areas to total image area.
"""
gray = cv2.cvtColor(image_rgb, cv2.COLOR_RGB2GRAY)
h, w = gray.shape
# We must operate in float32 for continuous math
u = gray.astype(np.float32) / 255.0
# Horizontal gradients
gx = np.zeros_like(u)
gx[:, :-1] = u[:, 1:] - u[:, :-1]
gx_dx = np.zeros_like(u)
gx_dx[:, :-2] = u[:, 2:] - u[:, 1:-1]
# Vertical gradients
gy = np.zeros_like(u)
gy[:-1, :] = u[1:, :] - u[:-1, :]
gy_dy = np.zeros_like(u)
gy_dy[:-2, :] = u[2:, :] - u[1:-1, :]
# Heaviside zero-crossing detection
hx = (np.pi / 2.0 + np.arctan(-300.0 * gx * gx_dx)) / np.pi
hy = (np.pi / 2.0 + np.arctan(-300.0 * gy * gy_dy)) / np.pi
# Gaussian edge suppression
wx = np.exp(-((gx + gx_dx)**2) * 50.0)
wy = np.exp(-((gy + gy_dy)**2) * 50.0)
# Final SWN map
swn = hx * hy * wx * wy
# ── Percentile contrast stretching ──
p_low, p_high = np.percentile(swn, [1, 99])
if p_high - p_low > 1e-8:
swn_stretched = np.clip((swn - p_low) / (p_high - p_low), 0, 1)
else:
swn_stretched = cv2.normalize(swn, None, 0, 1, cv2.NORM_MINMAX)
swn_uint8 = np.uint8(255 * swn_stretched)
swn_colored = cv2.applyColorMap(swn_uint8, cv2.COLORMAP_INFERNO)
# ── Blend with original grayscale for spatial context ──
gray_3ch = cv2.cvtColor(gray, cv2.COLOR_GRAY2BGR)
blended = cv2.addWeighted(swn_colored, 0.7, gray_3ch, 0.3, 0)
# ── Anomaly contour detection (high-noise regions) ──
mean_s = np.mean(swn_stretched)
std_s = np.std(swn_stretched) + 1e-8
anomaly_mask = ((swn_stretched - mean_s) / std_s > 2.0).astype(np.uint8)
# Morphological cleanup
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
anomaly_mask = cv2.morphologyEx(anomaly_mask, cv2.MORPH_CLOSE, kernel)
anomaly_mask = cv2.morphologyEx(anomaly_mask, cv2.MORPH_OPEN, kernel)
contours, _ = cv2.findContours(anomaly_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
# Filter tiny contours
significant = [c for c in contours if cv2.contourArea(c) > 50]
cv2.drawContours(blended, significant, -1, (0, 255, 255), 2, cv2.LINE_AA)
# ── Colorbar legend strip ──
bar_w = max(16, w // 40)
colorbar = np.linspace(255, 0, h).astype(np.uint8).reshape(-1, 1)
colorbar = np.repeat(colorbar, bar_w, axis=1)
colorbar_colored = cv2.applyColorMap(colorbar, cv2.COLORMAP_INFERNO)
cv2.putText(colorbar_colored, "High", (2, 16),
cv2.FONT_HERSHEY_SIMPLEX, 0.3, (255, 255, 255), 1, cv2.LINE_AA)
cv2.putText(colorbar_colored, "Low", (2, h - 6),
cv2.FONT_HERSHEY_SIMPLEX, 0.3, (255, 255, 255), 1, cv2.LINE_AA)
final = np.hstack([blended, colorbar_colored])
if save_path:
cv2.imwrite(save_path, final)
total_anomaly_area = sum(cv2.contourArea(c) for c in significant)
anomaly_ratio = float(total_anomaly_area / (h * w))
return final, anomaly_ratio
def compute_cepstrum(image_rgb, save_path=None):
"""
Computes the 2D Cepstrum (spectrum of a spectrum) to detect resampling and rotation echoes.
Applies a 2D Hanning window to prevent edge discontinuities (standard signal processing practice).
"""
gray = cv2.cvtColor(image_rgb, cv2.COLOR_RGB2GRAY)
# Apply 2D Hanning window
h, w = gray.shape
window_2d = np.outer(np.hanning(h), np.hanning(w))
gray_windowed = gray * window_2d
# Forward 2D FFT
f_transform = np.fft.fft2(gray_windowed)
f_shift = np.fft.fftshift(f_transform)
# Log magnitude spectrum
log_mag = np.log(np.abs(f_shift) + 1.0)
# Inverse FFT of the log magnitude -> Cepstrum
cepstrum = np.abs(np.fft.ifft2(log_mag))
cepstrum_shift = np.fft.fftshift(cepstrum)
# To enhance visualization, apply log scaling to cepstrum and normalize
# We zero out the massive DC component in the exact center
cy, cx = cepstrum_shift.shape[0]//2, cepstrum_shift.shape[1]//2
cepstrum_shift[cy-3:cy+4, cx-3:cx+4] = 0
cepstrum_log = np.log(cepstrum_shift + 1.0)
cepstrum_normalized = cv2.normalize(cepstrum_log, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8)
cepstrum_colored = cv2.applyColorMap(cepstrum_normalized, cv2.COLORMAP_JET)
if save_path:
cv2.imwrite(save_path, cepstrum_colored)
return cepstrum_colored, cepstrum_shift
def compute_dwt_diagonal(image_rgb, save_path=None):
"""
Computes 2D Discrete Wavelet Transform and extracts the 4-band coefficients (LL, LH, HL, HH)
stitched into a 2x2 visualization grid, mathematically equivalent to MATLAB's dwt2 display.
"""
gray = cv2.cvtColor(image_rgb, cv2.COLOR_RGB2GRAY)
# Compute 2D DWT using Haar wavelet
coeffs2 = pywt.dwt2(gray, 'haar')
LL, (LH, HL, HH) = coeffs2
# Normalize each band
ll_norm = cv2.normalize(LL, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8)
lh_norm = cv2.normalize(np.abs(LH), None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8)
hl_norm = cv2.normalize(np.abs(HL), None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8)
hh_norm = cv2.normalize(np.abs(HH), None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8)
# Combine into 2x2 grid
top_row = np.hstack((ll_norm, lh_norm))
bottom_row = np.hstack((hl_norm, hh_norm))
grid = np.vstack((top_row, bottom_row))
grid_colored = cv2.applyColorMap(grid, cv2.COLORMAP_MAGMA)
# Add crosshair separators for clarity
h, w = grid_colored.shape[:2]
cv2.line(grid_colored, (w//2, 0), (w//2, h), (255, 255, 255), 1)
cv2.line(grid_colored, (0, h//2), (w, h//2), (255, 255, 255), 1)
if save_path:
cv2.imwrite(save_path, grid_colored)
return grid_colored, np.abs(HH)
def compute_dct_spectrum(image_rgb, save_path=None):
"""
Compute an enhanced 2D DCT spectrum visualization.
DCT places DC at top-left (0,0), with frequency increasing toward bottom-right.
Uses percentile contrast stretching, frequency band arcs from origin,
and a perceptually uniform colormap for forensic analysis.
Returns:
dct_log: Log-scaled DCT coefficient matrix.
dct_colored: Colorized visualization image.
dct_hf_ratio: Ratio of high-frequency DCT energy to total DCT energy
(independent of FFT; based on diagonal distance from DC).
"""
gray = cv2.cvtColor(image_rgb, cv2.COLOR_RGB2GRAY)
gray_float = np.float32(gray) / 255.0
# Apply 2D DCT
dct_result = cv2.dct(gray_float)
# ── DCT-specific high-frequency energy ratio ──
# In a 2D DCT, DC is at (0,0) and frequency increases toward bottom-right.
# We measure the energy ratio of coefficients beyond 70% of the max diagonal.
h, w = dct_result.shape
Y_idx, X_idx = np.ogrid[:h, :w]
max_diag = np.sqrt(float(h**2 + w**2))
diag_dist = np.sqrt(X_idx.astype(np.float64)**2 + Y_idx.astype(np.float64)**2)
dct_power = dct_result.astype(np.float64) ** 2
total_energy = np.sum(dct_power)
hf_mask = diag_dist > (max_diag * 0.70)
hf_energy = np.sum(dct_power[hf_mask])
dct_hf_ratio = float(hf_energy / total_energy) if total_energy > 0 else 0.0
# Log scale for visualization
dct_log = np.log(np.abs(dct_result) + 1e-10)
# ── Percentile contrast stretching ──
p_low, p_high = np.percentile(dct_log, [2, 98])
if p_high - p_low > 1e-8:
dct_stretched = np.clip((dct_log - p_low) / (p_high - p_low), 0, 1)
else:
dct_stretched = cv2.normalize(dct_log, None, 0, 1, cv2.NORM_MINMAX)
dct_uint8 = np.uint8(255 * dct_stretched)
# ── Perceptually uniform colormap ──
dct_colored = cv2.applyColorMap(dct_uint8, cv2.COLORMAP_INFERNO)
# ── Frequency band arcs from origin (0,0) ──
origin = (0, 0)
max_radius = int(max_diag)
band_fractions = [0.05, 0.15, 0.35, 0.65]
band_labels = ["DC", "Low-Freq", "Mid-Freq", "High-Freq"]
for frac, label in zip(band_fractions, band_labels):
r = int(max_radius * frac)
# Draw quarter-circle arc from origin
cv2.ellipse(dct_colored, origin, (r, r), 0, 0, 90, (255, 255, 255), 1, cv2.LINE_AA)
# Place label along the diagonal
diag_x = int(r * 0.707) # cos(45°)
diag_y = int(r * 0.707) # sin(45°)
if diag_x < w - 60 and diag_y < h - 12:
cv2.putText(dct_colored, label, (diag_x + 4, diag_y - 4),
cv2.FONT_HERSHEY_SIMPLEX, 0.35, (255, 255, 255), 1, cv2.LINE_AA)
# Diagonal guide line (DC → HF direction)
cv2.line(dct_colored, (0, 0), (w - 1, h - 1), (255, 255, 255, 80), 1, cv2.LINE_AA)
# Corner annotations
cv2.putText(dct_colored, "DC", (6, 18),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 255, 100), 1, cv2.LINE_AA)
cv2.putText(dct_colored, "HF", (w - 30, h - 8),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (100, 200, 255), 1, cv2.LINE_AA)
if save_path:
cv2.imwrite(save_path, dct_colored)
return dct_log, dct_colored, dct_hf_ratio
def compute_block_dct_artifacts(image_rgb, save_path=None):
"""
Computes 8x8 block-wise DCT to detect local frequency anomalies.
Spliced deepfakes often disrupt the 8x8 JPEG grid, causing localized
mismatches in block-wise high-frequency energy.
This function uses highly-parallelized vectorized tensor multiplication (A @ X @ A^T)
to compute the 2D DCT for all 8x8 blocks simultaneously.
"""
gray = cv2.cvtColor(image_rgb, cv2.COLOR_RGB2GRAY)
h, w = gray.shape
# Pad to multiple of 8
pad_h = (8 - h % 8) % 8
pad_w = (8 - w % 8) % 8
gray_padded = np.pad(gray, ((0, pad_h), (0, pad_w)), mode='reflect')
ph, pw = gray_padded.shape
gray_float = np.float32(gray_padded) - 128.0 # center around 0
# Construct 8x8 DCT-II Transform Matrix A
N = 8
A = np.zeros((N, N), dtype=np.float32)
for k in range(N):
for n in range(N):
alpha = np.sqrt(1.0 / N) if k == 0 else np.sqrt(2.0 / N)
A[k, n] = alpha * np.cos(np.pi * (2*n + 1) * k / (2*N))
# Reshape image into tensor of shape (num_blocks_h, num_blocks_w, 8, 8)
num_blocks_h, num_blocks_w = ph // 8, pw // 8
X_blocks = gray_float.reshape(num_blocks_h, 8, num_blocks_w, 8).transpose(0, 2, 1, 3)
# Vectorized 2D DCT: Y = A @ X @ A^T across the entire image grid simultaneously
Y_blocks = A @ X_blocks @ A.T
# Create mask for high-frequency coefficients (bottom right triangle)
mask = np.tri(8, 8, -3, dtype=bool).T
# Sum the absolute values of HF coefficients
block_hf_energy = np.sum(np.abs(Y_blocks) * mask, axis=(2, 3))
# ── Log-compress dynamic range to reveal subtle block variations ──
block_log = np.log1p(block_hf_energy)
# ── Detect anomalous blocks (z-score > 2.0) ──
mean_e = np.mean(block_log)
std_e = np.std(block_log) + 1e-8
z_scores = (block_log - mean_e) / std_e
anomaly_mask_blocks = (np.abs(z_scores) > 2.0).astype(np.uint8)
# ── Bilinear upscale for smooth heatmap ──
heatmap_smooth = cv2.resize(block_log, (w, h), interpolation=cv2.INTER_LINEAR)
# Percentile contrast stretching
p_low, p_high = np.percentile(heatmap_smooth, [1, 99])
if p_high - p_low > 1e-8:
heatmap_norm = np.clip((heatmap_smooth - p_low) / (p_high - p_low), 0, 1)
else:
heatmap_norm = cv2.normalize(heatmap_smooth, None, 0, 1, cv2.NORM_MINMAX)
heatmap_uint8 = np.uint8(255 * heatmap_norm)
heatmap_colored = cv2.applyColorMap(heatmap_uint8, cv2.COLORMAP_INFERNO)
# ── Blend heatmap with original grayscale for spatial context ──
gray_3ch = cv2.cvtColor(gray[:h, :w], cv2.COLOR_GRAY2BGR)
blended = cv2.addWeighted(heatmap_colored, 0.7, gray_3ch, 0.3, 0)
# ── Draw anomaly contours ──
anomaly_upscaled = cv2.resize(anomaly_mask_blocks, (w, h), interpolation=cv2.INTER_NEAREST)
contours, _ = cv2.findContours(anomaly_upscaled, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
cv2.drawContours(blended, contours, -1, (0, 255, 255), 2, cv2.LINE_AA) # cyan outlines
# ── Colorbar legend strip on right edge ──
bar_w = max(16, w // 40)
colorbar = np.linspace(255, 0, h).astype(np.uint8).reshape(-1, 1)
colorbar = np.repeat(colorbar, bar_w, axis=1)
colorbar_colored = cv2.applyColorMap(colorbar, cv2.COLORMAP_INFERNO)
# Labels
cv2.putText(colorbar_colored, "High", (2, 16),
cv2.FONT_HERSHEY_SIMPLEX, 0.3, (255, 255, 255), 1, cv2.LINE_AA)
cv2.putText(colorbar_colored, "Low", (2, h - 6),
cv2.FONT_HERSHEY_SIMPLEX, 0.3, (255, 255, 255), 1, cv2.LINE_AA)
# Attach colorbar to the right side
final = np.hstack([blended, colorbar_colored])
if save_path:
cv2.imwrite(save_path, final)
# Calculate variance
block_variance = float(np.var(block_hf_energy))
return block_hf_energy, final, block_variance
def get_2d_hanning_window(shape):
"""
Creates a 2D Hanning window to prevent spectral leakage.
Images have non-periodic boundaries, causing artificial vertical/horizontal
energy in the FFT (the bright cross artifact). Windowing fades the edges to zero.
"""
h, w = shape
win_h = np.hanning(h)
win_w = np.hanning(w)
return np.outer(win_h, win_w)
def compute_fft_magnitude(image_rgb, save_path=None):
"""
Compute an enhanced 2D FFT magnitude spectrum with the DC component centered.
Applies a 2D Hanning window to eliminate spectral leakage.
Includes frequency band rings, percentile contrast, and an embedded
radial profile mini-chart for forensic analysis.
"""
gray = cv2.cvtColor(image_rgb, cv2.COLOR_RGB2GRAY)
h, w = gray.shape
# Apply Hanning window
window = get_2d_hanning_window(gray.shape)
gray_windowed = gray * window
# 2D FFT
f_transform = np.fft.fft2(gray_windowed)
f_shift = np.fft.fftshift(f_transform)
# Magnitude spectrum (log scale)
magnitude = 20 * np.log(np.abs(f_shift) + 1e-10)
# ── Percentile contrast stretching ──
p_low, p_high = np.percentile(magnitude, [2, 99])
if p_high - p_low > 1e-8:
mag_stretched = np.clip((magnitude - p_low) / (p_high - p_low), 0, 1)
else:
mag_stretched = cv2.normalize(magnitude, None, 0, 1, cv2.NORM_MINMAX)
mag_uint8 = np.uint8(255 * mag_stretched)
mag_colored = cv2.applyColorMap(mag_uint8, cv2.COLORMAP_INFERNO)
# ── Frequency band rings from center ──
center = (w // 2, h // 2)
max_radius = min(h // 2, w // 2)
band_fractions = [0.05, 0.15, 0.35, 0.65]
band_labels = ["DC", "Low", "Mid", "High"]
for frac, label in zip(band_fractions, band_labels):
r = int(max_radius * frac)
cv2.circle(mag_colored, center, r, (255, 255, 255), 1, cv2.LINE_AA)
label_x = center[0] + r + 4
label_y = center[1] - 4
if label_x + 40 < w:
cv2.putText(mag_colored, label, (label_x, label_y),
cv2.FONT_HERSHEY_SIMPLEX, 0.35, (255, 255, 255), 1, cv2.LINE_AA)
# Axis crosshair (subtle)
cv2.line(mag_colored, (w // 2, 0), (w // 2, h), (255, 255, 255), 1, cv2.LINE_AA)
cv2.line(mag_colored, (0, h // 2), (w, h // 2), (255, 255, 255), 1, cv2.LINE_AA)
# ── Embedded radial profile mini-chart (bottom-right corner) ──
# Compute azimuthal average
Y, X = np.ogrid[:h, :w]
dist = np.sqrt((X - w // 2) ** 2 + (Y - h // 2) ** 2).astype(int)
max_r = min(h // 2, w // 2)
radial = np.zeros(max_r)
for r_val in range(max_r):
ring = magnitude[dist == r_val]
if len(ring) > 0:
radial[r_val] = np.mean(ring)
# Draw mini chart
chart_w, chart_h = min(160, w // 4), min(80, h // 5)
chart_x, chart_y = w - chart_w - 10, h - chart_h - 10
# Semi-transparent background
overlay = mag_colored.copy()
cv2.rectangle(overlay, (chart_x - 4, chart_y - 14), (chart_x + chart_w + 4, chart_y + chart_h + 4), (0, 0, 0), -1)
cv2.addWeighted(overlay, 0.6, mag_colored, 0.4, 0, mag_colored)
# Normalize radial profile to chart height
r_min, r_max = np.min(radial), np.max(radial)
if r_max - r_min > 1e-8:
r_norm = (radial - r_min) / (r_max - r_min)
else:
r_norm = np.zeros_like(radial)
# Draw the profile line
step = max(1, len(r_norm) // chart_w)
r_sampled = r_norm[::step][:chart_w]
pts = []
for i, val in enumerate(r_sampled):
px = chart_x + i
py = chart_y + chart_h - int(val * chart_h)
pts.append((px, py))
if len(pts) > 1:
for k in range(len(pts) - 1):
cv2.line(mag_colored, pts[k], pts[k + 1], (0, 255, 200), 1, cv2.LINE_AA)
# Chart label
cv2.putText(mag_colored, "Radial Profile", (chart_x, chart_y - 4),
cv2.FONT_HERSHEY_SIMPLEX, 0.3, (200, 200, 200), 1, cv2.LINE_AA)
if save_path:
cv2.imwrite(save_path, mag_colored)
return magnitude, mag_colored
def azimuthal_average(image_rgb):
"""
Compute the azimuthal (radial) average of the FFT power spectrum.
"""
gray = cv2.cvtColor(image_rgb, cv2.COLOR_RGB2GRAY)
# Apply Hanning window
window = get_2d_hanning_window(gray.shape)
gray_windowed = gray * window
f_transform = np.fft.fft2(gray_windowed)
f_shift = np.fft.fftshift(f_transform)
power_spectrum = np.abs(f_shift) ** 2
h, w = power_spectrum.shape
cy, cx = h // 2, w // 2
# Create radius map
Y, X = np.ogrid[:h, :w]
r = np.sqrt((X - cx) ** 2 + (Y - cy) ** 2).astype(int)
max_r = min(cy, cx)
# Compute radial average
radial_profile = np.zeros(max_r)
for i in range(max_r):
mask = r == i
if np.any(mask):
radial_profile[i] = np.mean(power_spectrum[mask])
return radial_profile
def compute_high_freq_energy_ratio(image_rgb):
"""
Compute the ratio of high-frequency energy to total energy.
Deepfakes often have lower high-frequency energy because
neural networks struggle to reproduce fine texture details.
Returns a value between 0 and 1.
Lower values suggest potential manipulation.
"""
gray = cv2.cvtColor(image_rgb, cv2.COLOR_RGB2GRAY)
# Apply Hanning window
window = get_2d_hanning_window(gray.shape)
gray_windowed = gray * window
f_transform = np.fft.fft2(gray_windowed)
f_shift = np.fft.fftshift(f_transform)
power = np.abs(f_shift) ** 2
h, w = power.shape
cy, cx = h // 2, w // 2
# Define high-frequency region (outer 30% of spectrum)
Y, X = np.ogrid[:h, :w]
r = np.sqrt((X - cx) ** 2 + (Y - cy) ** 2)
max_r = min(cy, cx)
high_freq_mask = r > (max_r * 0.7)
total_energy = np.sum(power)
high_freq_energy = np.sum(power[high_freq_mask])
if total_energy == 0:
return 0.5
ratio = high_freq_energy / total_energy
return float(ratio)
def compute_per_channel_hf_ratio(image_rgb):
"""
Compute the high-frequency energy ratio for each R, G, B channel independently.
GANs often introduce channel-specific artifacts — e.g., the Blue channel
may have drastically different high-freq energy than Red or Green.
High cross-channel variance is a strong deepfake signal.
"""
ratios = []
h_img, w_img = image_rgb.shape[:2]
window = get_2d_hanning_window((h_img, w_img))
for ch in range(3):
channel = image_rgb[:, :, ch]
channel_windowed = channel * window
f_transform = np.fft.fft2(channel_windowed)
f_shift = np.fft.fftshift(f_transform)
power = np.abs(f_shift) ** 2
h, w = power.shape
cy, cx = h // 2, w // 2
Y, X = np.ogrid[:h, :w]
r = np.sqrt((X - cx) ** 2 + (Y - cy) ** 2)
max_r = min(cy, cx)
high_freq_mask = r > (max_r * 0.7)
total = np.sum(power)
high = np.sum(power[high_freq_mask])
ratios.append(high / total if total > 0 else 0.5)
# Cross-channel variance: high variance = suspicious
channel_variance = float(np.std(ratios))
return ratios, channel_variance
def pca_spectral_decomposition(image_rgb, output_dir, prefix="freq"):
"""
Perform PCA on the flattened RGB pixel matrix to extract principal components.
The 3rd principal component (PC3) often reveals hidden GAN artifacts
that are invisible in normal RGB space — similar to how hyperspectral
imaging reveals materials invisible to the naked eye.
"""
h, w, c = image_rgb.shape
pixels = image_rgb.reshape(-1, c).astype(np.float64)
# Center the data
mean = np.mean(pixels, axis=0)
centered = pixels - mean
# Covariance and eigen decomposition
cov = np.cov(centered.T)
eigenvalues, eigenvectors = np.linalg.eigh(cov)
# Sort by largest eigenvalue
idx = np.argsort(eigenvalues)[::-1]
eigenvalues = eigenvalues[idx]
eigenvectors = eigenvectors[:, idx]
# Project onto principal components
projected = centered @ eigenvectors
# PC3 (the least variance component) often contains GAN residuals
pc3 = projected[:, 2].reshape(h, w)
pc3_norm = cv2.normalize(pc3, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8)
pc3_colored = cv2.applyColorMap(pc3_norm, cv2.COLORMAP_TWILIGHT)
pca_path = os.path.join(output_dir, f"{prefix}_pca_pc3.jpg")
cv2.imwrite(pca_path, pc3_colored)
# Variance ratio: how much info is in PC3 relative to total
total_var = np.sum(eigenvalues)
pc3_var_ratio = eigenvalues[2] / total_var if total_var > 0 else 0
return pca_path.replace("\\", "/"), float(pc3_var_ratio)
def compute_high_pass_filter(image_rgb, save_path=None):
"""
Extracts the high-frequency spatial components of the image using an FFT High-Pass Filter.
This reveals hidden splicing boundaries and texture inconsistencies that are
smoothed over in the low frequencies.
Returns:
img_colored: Visualization of the high-pass filtered image.
hpf_variance: Variance of the high-pass filtered output, measuring HF energy.
"""
gray = cv2.cvtColor(image_rgb, cv2.COLOR_RGB2GRAY)
f_transform = np.fft.fft2(gray)
f_shift = np.fft.fftshift(f_transform)
h, w = gray.shape
cy, cx = h // 2, w // 2
# Create high-pass mask (block low frequencies around DC component)
mask = np.ones((h, w), np.uint8)
r = int(min(h, w) * 0.05) # block the inner 5% low frequencies
y, x = np.ogrid[:h, :w]
mask_area = (x - cx)**2 + (y - cy)**2 <= r**2
mask[mask_area] = 0
# Apply mask and inverse FFT
f_shift_filtered = f_shift * mask
f_ishift = np.fft.ifftshift(f_shift_filtered)
img_back = np.abs(np.fft.ifft2(f_ishift))
# Normalize for visualization (amplify weak edges)
img_back_norm = cv2.normalize(img_back, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8)
img_colored = cv2.applyColorMap(img_back_norm, cv2.COLORMAP_BONE)
if save_path:
cv2.imwrite(save_path, img_colored)
hpf_variance = float(np.var(img_back))
return img_colored, hpf_variance
def compute_phase_spectrum(image_rgb, save_path=None):
"""
Compute the Phase Spectrum of the image.
The phase contains the structural information of the image. Splicing or face swapping
often disrupts the continuous phase coherence, which can appear as anomalies in this view.
"""
gray = cv2.cvtColor(image_rgb, cv2.COLOR_RGB2GRAY)
f_transform = np.fft.fft2(gray)
f_shift = np.fft.fftshift(f_transform)
phase = np.angle(f_shift)
# Normalize phase from [-pi, pi] to [0, 255]
phase_normalized = cv2.normalize(phase, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8)
phase_colored = cv2.applyColorMap(phase_normalized, cv2.COLORMAP_OCEAN)
if save_path:
cv2.imwrite(save_path, phase_colored)
phase_variance = float(np.var(phase))
return phase_colored, phase_variance
def compute_spectral_residual_saliency(image_rgb, save_path=None):
"""
Isolates GAN/Deepfake Transpose Convolution grids using Spectral Residuals.
Computes Log Amplitude Spectrum, subtracts the smoothed (box-filtered) version,
and reconstructs the image using Inverse FFT.
The resulting Saliency Map highlights periodic high-frequency noise (like checkerboards).
"""
gray = cv2.cvtColor(image_rgb, cv2.COLOR_RGB2GRAY)
gray_float = gray.astype(np.float32) / 255.0
# 1. Forward FFT
f_transform = np.fft.fft2(gray_float)
# 2. Extract Amplitude and Phase
amplitude = np.abs(f_transform)
phase = np.angle(f_transform)
# 3. Log Amplitude
log_amplitude = np.log(amplitude + 1e-8)
# 4. Spectral Residual = LogAmplitude - Smoothed(LogAmplitude)
kernel = np.ones((3, 3), np.float32) / 9.0
spectral_residual = log_amplitude - cv2.filter2D(log_amplitude, -1, kernel)
# 5. Inverse FFT to reconstruct Saliency Map
complex_exp = np.exp(spectral_residual + 1j * phase)
saliency_map = np.abs(np.fft.ifft2(complex_exp))**2
# 6. Gaussian smoothing for visual clarity
saliency_map = cv2.GaussianBlur(saliency_map, (5, 5), 0)
# Normalize for visualization
smap_norm = cv2.normalize(saliency_map, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8)
smap_colored = cv2.applyColorMap(smap_norm, cv2.COLORMAP_HOT)
if save_path:
cv2.imwrite(save_path, smap_colored)
saliency_variance = float(np.var(saliency_map))
return smap_colored, saliency_variance
def analyze_frequency_domain(image_rgb, output_dir, prefix="freq", quality_multiplier=1.0):
"""
Run full frequency domain analysis on an image.
Returns a dict with all computed metrics and paths to saved visualizations.
"""
os.makedirs(output_dir, exist_ok=True)
# DCT spectrum
dct_path = os.path.join(output_dir, f"{prefix}_dct_spectrum.jpg")
dct_log, _, dct_hf_ratio = compute_dct_spectrum(image_rgb, save_path=dct_path)
# FFT magnitude
fft_path = os.path.join(output_dir, f"{prefix}_fft_magnitude.jpg")
fft_mag, _ = compute_fft_magnitude(image_rgb, save_path=fft_path)
# High-frequency energy ratio
hf_ratio = compute_high_freq_energy_ratio(image_rgb)
# Azimuthal average
radial_profile = azimuthal_average(image_rgb)
# =========================================
# IMPROVED: Continuous spectral anomaly scoring (Calibrated via IQA)
# =========================================
t1 = 0.001 * quality_multiplier
t2 = 0.0002 * quality_multiplier
t3 = 0.00005 * quality_multiplier
if hf_ratio >= t1:
spectral_anomaly = 0.10
elif hf_ratio >= t2:
# Linear interpolation
t = (t1 - hf_ratio) / (t1 - t2)
spectral_anomaly = 0.10 + t * 0.15
elif hf_ratio >= t3:
# Linear interpolation
t = (t2 - hf_ratio) / (t2 - t3)
spectral_anomaly = 0.25 + t * 0.30
else:
# Extremely low
spectral_anomaly = 0.75
# Additional signal: azimuthal 1/f deviation
deviation = 0.0
if len(radial_profile) > 10:
log_profile = np.log(radial_profile[1:] + 1e-10)
x = np.arange(len(log_profile))
coeffs = np.polyfit(x, log_profile, 1)
fitted = np.polyval(coeffs, x)
deviation = np.std(log_profile - fitted)
if deviation > 2.0:
spectral_anomaly = min(spectral_anomaly + 0.15, 0.95)
elif deviation > 1.0:
spectral_anomaly = min(spectral_anomaly + 0.05, 0.90)
# =========================================
# NEW: Per-channel spectral consistency
# =========================================
channel_ratios, channel_variance = compute_per_channel_hf_ratio(image_rgb)
# High cross-channel variance is a strong GAN fingerprint. Calibrated via IQA.
t_cv1 = 0.02 * (1.0 / quality_multiplier)
t_cv2 = 0.01 * (1.0 / quality_multiplier)
if channel_variance > t_cv1:
spectral_anomaly = min(spectral_anomaly + 0.20, 0.95)
elif channel_variance > t_cv2:
spectral_anomaly = min(spectral_anomaly + 0.10, 0.90)
# =========================================
# PCA spectral decomposition
pca_path, pc3_var_ratio = pca_spectral_decomposition(image_rgb, output_dir, prefix)
# If PC3 carries unusually high variance, hidden artifacts exist
t_pc3 = 0.05 * (1.0 / quality_multiplier)
if pc3_var_ratio > t_pc3:
spectral_anomaly = min(spectral_anomaly + 0.10, 0.95)
# =========================================
# NEW: High-Pass Spatial Filter & Phase Spectrum & Block DCT
# =========================================
hpf_path = os.path.join(output_dir, f"{prefix}_high_pass.jpg")
_, hpf_variance = compute_high_pass_filter(image_rgb, save_path=hpf_path)
# Phase spectrum
phase_path = os.path.join(output_dir, f"{prefix}_phase_spectrum.jpg")
_, phase_variance = compute_phase_spectrum(image_rgb, save_path=phase_path)
block_dct_path = os.path.join(output_dir, f"{prefix}_block_dct.jpg")
_, _, block_variance = compute_block_dct_artifacts(image_rgb, save_path=block_dct_path)
swn_path = os.path.join(output_dir, f"{prefix}_swn_noise.jpg")
_, swn_anomaly_ratio = compute_swn_noise_map(image_rgb, save_path=swn_path)
# =========================================
# NEW: Spectral Residual Saliency
# =========================================
saliency_path = os.path.join(output_dir, f"{prefix}_saliency_map.jpg")
_, saliency_variance = compute_spectral_residual_saliency(image_rgb, save_path=saliency_path)
# If a distinct grid pattern appears, saliency variance explodes (bright spots on black bg)
# Real photos have low saliency variance (smooth low-intensity edges)
if saliency_variance > 1500.0:
spectral_anomaly = min(spectral_anomaly + 0.20, 0.95)
elif saliency_variance > 500.0:
spectral_anomaly = min(spectral_anomaly + 0.10, 0.90)
# =========================================
# NEW: Cepstrum and DWT
# =========================================
cepstrum_path = os.path.join(output_dir, f"{prefix}_cepstrum.jpg")
_, cepstrum_data = compute_cepstrum(image_rgb, save_path=cepstrum_path)
dwt_path = os.path.join(output_dir, f"{prefix}_dwt_diagonal.jpg")
_, hh_data = compute_dwt_diagonal(image_rgb, save_path=dwt_path)
cepstrum_var = float(np.var(cepstrum_data))
dwt_var = float(np.var(hh_data))
if dwt_var < 0.5:
spectral_anomaly = min(spectral_anomaly + 0.15, 0.95)
if cepstrum_var > 0.05:
spectral_anomaly = min(spectral_anomaly + 0.15, 0.95)
spectral_anomaly = float(np.clip(spectral_anomaly, 0, 1))
# Calculate explicit individual verdicts and reasons for the UI
verdicts = {
"fft": {
"status": "Pass" if hf_ratio > 0.0001 else "Fail",
"reason": f"High-Freq ratio {hf_ratio:.5f} > 0.0001" if hf_ratio > 0.0001 else f"Synthetic lack of high-frequencies ({hf_ratio:.5f})"
},
"dct": {
"status": "Pass" if dct_hf_ratio > 0.00001 else "Fail",
"reason": f"DCT HF energy ratio {dct_hf_ratio:.6f} > 0.00001" if dct_hf_ratio > 0.00001 else f"Smoothed DCT spectrum ({dct_hf_ratio:.6f})"
},
"block_dct": {
"status": "Pass" if block_variance < 1000.0 else "Fail" if block_variance > 5000.0 else "Warning",
"reason": f"Normal block variance ({block_variance:.1f})" if block_variance < 1000.0 else f"Disrupted JPEG grid ({block_variance:.1f})"
},
"high_pass": {
"status": "Pass" if hpf_variance > 100.0 else "Fail",
"reason": f"Normal HF edge energy ({hpf_variance:.1f})" if hpf_variance > 100.0 else f"Smoothed HF edges ({hpf_variance:.1f})"
},
"phase": {
"status": "Pass" if phase_variance > 1.5 else "Fail" if phase_variance < 0.5 else "Warning",
"reason": f"Phase coherent ({phase_variance:.2f})" if phase_variance > 1.5 else f"Phase disruption detected ({phase_variance:.2f})"
},
"swn": {
"status": "Pass" if swn_anomaly_ratio < 0.01 else "Fail" if swn_anomaly_ratio > 0.05 else "Warning",
"reason": f"No spliced edges" if swn_anomaly_ratio < 0.01 else f"Detected zero-crossing anomalies ({swn_anomaly_ratio:.3f})"
},
"pca": {
"status": "Pass" if pc3_var_ratio < 0.05 else "Fail",
"reason": f"Normal PCA residuals ({pc3_var_ratio:.3f})" if pc3_var_ratio < 0.05 else f"Hidden periodic artifacts ({pc3_var_ratio:.3f})"
},
"saliency": {
"status": "Pass" if saliency_variance < 500.0 else "Fail" if saliency_variance > 1500.0 else "Warning",
"reason": f"Natural smooth edges" if saliency_variance < 500.0 else f"Detected periodic GAN grid ({saliency_variance:.1f})"
},
"cepstrum": {
"status": "Pass" if cepstrum_var < 0.03 else "Fail" if cepstrum_var > 0.05 else "Warning",
"reason": f"No structural echoes ({cepstrum_var:.4f})" if cepstrum_var < 0.03 else f"Resampling echoes detected ({cepstrum_var:.4f})"
},
"dwt": {
"status": "Pass" if dwt_var > 1.0 else "Fail" if dwt_var < 0.5 else "Warning",
"reason": f"Natural diagonal noise ({dwt_var:.2f})" if dwt_var > 1.0 else f"Synthetic lack of diagonal detail ({dwt_var:.2f})"
}
}
# Generate Explanation
explanation = {
"what_happened": "Fourier Transforms (FFT) and Discrete Cosine Transforms (DCT) were applied to analyze the frequency-domain spectrum of the image.",
"result": "Spectral energy appears naturally distributed." if spectral_anomaly < 0.5 else "Detected synthetic spectral fingerprints, such as high-frequency GAN checkerboarding or unusual periodic artifacts.",
"why_it_happened": "Neural networks generate images iteratively using transpose convolutions, which often leave behind microscopic, invisible 'checkerboard' artifacts in the high-frequency spectrum that physical cameras do not produce.",
"variables": {
"Spectral Anomaly Score": f"{spectral_anomaly:.2f}",
"High-Freq Energy Ratio": f"{hf_ratio:.5f}",
"Saliency Variance": f"{saliency_variance:.1f}",
"Block Variance": f"{block_variance:.1f}"
}
}
return {
"dct_spectrum_path": dct_path.replace("\\", "/"),
"block_dct_path": block_dct_path.replace("\\", "/"),
"fft_magnitude_path": fft_path.replace("\\", "/"),
"pca_spectrum_path": pca_path,
"high_pass_path": hpf_path.replace("\\", "/"),
"phase_spectrum_path": phase_path.replace("\\", "/"),
"swn_noise_path": swn_path.replace("\\", "/"),
"cepstrum_path": cepstrum_path.replace("\\", "/"),
"dwt_diagonal_path": dwt_path.replace("\\", "/"),
"saliency_map_path": saliency_path.replace("\\", "/"),
"high_freq_energy_ratio": round(hf_ratio, 6),
"dct_hf_ratio": round(dct_hf_ratio, 6),
"channel_hf_ratios": [round(r, 6) for r in channel_ratios],
"channel_variance": round(channel_variance, 6),
"pc3_variance_ratio": round(pc3_var_ratio, 6),
"block_variance": round(block_variance, 6),
"phase_variance": round(phase_variance, 6),
"swn_anomaly_ratio": round(swn_anomaly_ratio, 6),
"hpf_variance": round(hpf_variance, 6),
"cepstrum_var": round(cepstrum_var, 6),
"saliency_variance": round(saliency_variance, 6),
"dwt_var": round(dwt_var, 6),
"spectral_anomaly_score": round(spectral_anomaly, 4),
"radial_profile_length": len(radial_profile),
"radial_profile": [round(float(x), 4) for x in radial_profile] if len(radial_profile) > 0 else [],
"verdicts": verdicts,
"explanation": explanation
}