Mustafa Akcanca commited on
Commit ·
8582b96
1
Parent(s): 8251321
Migrate app
Browse files
src/tools/forensic/frequency_tools.py
CHANGED
|
@@ -8,32 +8,134 @@ import json
|
|
| 8 |
def analyze_frequency_domain(input_str: str) -> str:
|
| 9 |
"""
|
| 10 |
Analyze DCT/FFT frequency domain features.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
"""
|
| 12 |
image_path = input_str.strip()
|
| 13 |
try:
|
| 14 |
import numpy as np
|
|
|
|
| 15 |
from PIL import Image
|
| 16 |
-
from scipy import
|
|
|
|
| 17 |
|
| 18 |
img = Image.open(image_path).convert("RGB")
|
| 19 |
-
|
| 20 |
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
-
|
| 24 |
-
dct_energy = np.abs(dct_result)
|
| 25 |
|
| 26 |
-
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
result = {
|
| 30 |
"tool": "analyze_frequency_domain",
|
| 31 |
"status": "completed",
|
| 32 |
-
|
| 33 |
-
"dct_std": float(np.std(dct_energy)),
|
| 34 |
-
"fft_mean": float(np.mean(fft_magnitude)),
|
| 35 |
-
"fft_std": float(np.std(fft_magnitude)),
|
| 36 |
-
"note": "Basic frequency domain statistics extracted",
|
| 37 |
}
|
| 38 |
|
| 39 |
return json.dumps(result)
|
|
|
|
| 8 |
def analyze_frequency_domain(input_str: str) -> str:
|
| 9 |
"""
|
| 10 |
Analyze DCT/FFT frequency domain features.
|
| 11 |
+
|
| 12 |
+
Extracts comprehensive frequency domain features including:
|
| 13 |
+
- DCT coefficient statistics (mean, std)
|
| 14 |
+
- FFT radial profile statistics (mean, std, decay rate)
|
| 15 |
+
- Frequency band energies (low, mid, high)
|
| 16 |
+
- Peakiness metric for detecting upsampling artifacts
|
| 17 |
"""
|
| 18 |
image_path = input_str.strip()
|
| 19 |
try:
|
| 20 |
import numpy as np
|
| 21 |
+
import cv2
|
| 22 |
from PIL import Image
|
| 23 |
+
from scipy.fftpack import dct, fft2, fftshift
|
| 24 |
+
from scipy import stats
|
| 25 |
|
| 26 |
img = Image.open(image_path).convert("RGB")
|
| 27 |
+
image = np.array(img)
|
| 28 |
|
| 29 |
+
# Convert to grayscale using cv2 (matches frequency.py)
|
| 30 |
+
if len(image.shape) == 3:
|
| 31 |
+
gray = cv2.cvtColor(image, cv2.COLOR_RGB2GRAY)
|
| 32 |
+
else:
|
| 33 |
+
gray = image
|
| 34 |
|
| 35 |
+
features = {}
|
|
|
|
| 36 |
|
| 37 |
+
# --- DCT Features ---
|
| 38 |
+
# Compute 2D DCT (orthonormal normalization)
|
| 39 |
+
dct_map = dct(dct(gray.T, norm='ortho').T, norm='ortho')
|
| 40 |
+
|
| 41 |
+
# Histogram of DCT coefficients (excluding DC component)
|
| 42 |
+
dct_coeffs = dct_map.flatten()
|
| 43 |
+
dct_coeffs = dct_coeffs[1:] # Remove DC component
|
| 44 |
+
|
| 45 |
+
# Statistics on DCT coefficients (using absolute values)
|
| 46 |
+
dct_abs = np.abs(dct_coeffs)
|
| 47 |
+
features['dct_mean'] = float(np.mean(dct_abs))
|
| 48 |
+
features['dct_std'] = float(np.std(dct_abs))
|
| 49 |
+
|
| 50 |
+
# --- FFT Features ---
|
| 51 |
+
# Compute 2D FFT and shift to center DC component
|
| 52 |
+
f = fft2(gray.astype(np.float64)) # Use float64 for better precision
|
| 53 |
+
fshift = fftshift(f)
|
| 54 |
+
|
| 55 |
+
# Use log-magnitude spectrum (20*log10) for consistent normalization
|
| 56 |
+
# This matches the noiseprint extractor and provides better dynamic range
|
| 57 |
+
magnitude_spectrum = 20 * np.log10(np.abs(fshift) + 1e-10)
|
| 58 |
+
|
| 59 |
+
# Azimuthal average (Radial Profile)
|
| 60 |
+
# This computes the average magnitude at each radial distance from center
|
| 61 |
+
h, w = magnitude_spectrum.shape
|
| 62 |
+
cy, cx = h // 2, w // 2
|
| 63 |
+
y, x = np.ogrid[-cy:h-cy, -cx:w-cx]
|
| 64 |
+
r = np.sqrt(x**2 + y**2)
|
| 65 |
+
r = r.astype(int)
|
| 66 |
+
|
| 67 |
+
# Compute radial profile: average magnitude at each radius
|
| 68 |
+
tbin = np.bincount(r.ravel(), magnitude_spectrum.ravel())
|
| 69 |
+
nr = np.bincount(r.ravel())
|
| 70 |
+
radial_profile = tbin / np.maximum(nr, 1)
|
| 71 |
+
|
| 72 |
+
# Remove zero-radius (DC component) for better statistics
|
| 73 |
+
if len(radial_profile) > 1:
|
| 74 |
+
radial_profile_nonzero = radial_profile[1:]
|
| 75 |
+
else:
|
| 76 |
+
radial_profile_nonzero = radial_profile
|
| 77 |
+
|
| 78 |
+
# Summary stats of radial profile
|
| 79 |
+
features['fft_radial_mean'] = float(np.mean(radial_profile_nonzero))
|
| 80 |
+
features['fft_radial_std'] = float(np.std(radial_profile_nonzero))
|
| 81 |
+
|
| 82 |
+
# Improved radial decay metric: fit linear slope instead of assuming monotonic decay
|
| 83 |
+
# This is more robust to non-monotonic profiles (e.g., peaks at intermediate frequencies)
|
| 84 |
+
n = len(radial_profile_nonzero)
|
| 85 |
+
if n >= 3:
|
| 86 |
+
# Fit linear regression to log(radius) vs magnitude to estimate decay rate
|
| 87 |
+
# This captures the overall trend without assuming monotonicity
|
| 88 |
+
radii = np.arange(1, n + 1, dtype=np.float64)
|
| 89 |
+
# Use log(radius) to better capture power-law decay
|
| 90 |
+
log_radii = np.log(radii + 1e-10)
|
| 91 |
+
|
| 92 |
+
# Fit linear model: magnitude = a * log(radius) + b
|
| 93 |
+
# Negative slope indicates decay (typical for natural images)
|
| 94 |
+
# Positive slope indicates high-frequency emphasis (typical for upsampled images)
|
| 95 |
+
try:
|
| 96 |
+
slope, intercept, r_value, p_value, std_err = stats.linregress(
|
| 97 |
+
log_radii, radial_profile_nonzero
|
| 98 |
+
)
|
| 99 |
+
features['fft_radial_decay'] = float(slope) # Decay rate (negative = decay)
|
| 100 |
+
features['fft_radial_decay_r2'] = float(r_value**2) # Goodness of fit
|
| 101 |
+
except:
|
| 102 |
+
# Fallback: simple difference if regression fails
|
| 103 |
+
features['fft_radial_decay'] = float(
|
| 104 |
+
radial_profile_nonzero[0] - radial_profile_nonzero[-1]
|
| 105 |
+
)
|
| 106 |
+
features['fft_radial_decay_r2'] = 0.0
|
| 107 |
+
else:
|
| 108 |
+
features['fft_radial_decay'] = 0.0
|
| 109 |
+
features['fft_radial_decay_r2'] = 0.0
|
| 110 |
+
|
| 111 |
+
# Frequency band energies (low, mid, high)
|
| 112 |
+
if n >= 9: # Ensure enough samples for band division
|
| 113 |
+
# Divide radial profile into 3 bands: low, mid, high frequency
|
| 114 |
+
edges = np.linspace(0, n, 4, dtype=int) # 3 bands
|
| 115 |
+
low_band = radial_profile_nonzero[edges[0]:edges[1]]
|
| 116 |
+
mid_band = radial_profile_nonzero[edges[1]:edges[2]]
|
| 117 |
+
high_band = radial_profile_nonzero[edges[2]:edges[3]]
|
| 118 |
+
|
| 119 |
+
features['fft_low_energy'] = float(np.mean(low_band))
|
| 120 |
+
features['fft_mid_energy'] = float(np.mean(mid_band))
|
| 121 |
+
features['fft_high_energy'] = float(np.mean(high_band))
|
| 122 |
+
|
| 123 |
+
# Peakiness: ratio of max to mean (detects sharp peaks from upsampling)
|
| 124 |
+
# High peakiness indicates periodic patterns (upsampling artifacts)
|
| 125 |
+
profile_mean = np.mean(radial_profile_nonzero)
|
| 126 |
+
profile_max = np.max(radial_profile_nonzero)
|
| 127 |
+
features['fft_peakiness'] = float(profile_max / (profile_mean + 1e-10))
|
| 128 |
+
else:
|
| 129 |
+
# Not enough samples for band analysis
|
| 130 |
+
features['fft_low_energy'] = 0.0
|
| 131 |
+
features['fft_mid_energy'] = 0.0
|
| 132 |
+
features['fft_high_energy'] = 0.0
|
| 133 |
+
features['fft_peakiness'] = 0.0
|
| 134 |
|
| 135 |
result = {
|
| 136 |
"tool": "analyze_frequency_domain",
|
| 137 |
"status": "completed",
|
| 138 |
+
**features,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
}
|
| 140 |
|
| 141 |
return json.dumps(result)
|