File size: 6,134 Bytes
29b829b
 
 
 
 
 
 
 
 
 
8582b96
 
 
 
 
 
29b829b
 
 
 
8582b96
29b829b
8582b96
 
29b829b
 
8582b96
29b829b
8582b96
 
 
 
 
29b829b
8582b96
29b829b
8582b96
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29b829b
 
 
 
8582b96
29b829b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5196d55
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
"""
Frequency-domain forensic helpers.
"""

import json


def analyze_frequency_domain(input_str: str) -> str:
    """
    Analyze DCT/FFT frequency domain features.
    
    Extracts comprehensive frequency domain features including:
    - DCT coefficient statistics (mean, std)
    - FFT radial profile statistics (mean, std, decay rate)
    - Frequency band energies (low, mid, high)
    - Peakiness metric for detecting upsampling artifacts
    """
    image_path = input_str.strip()
    try:
        import numpy as np
        import cv2
        from PIL import Image
        from scipy.fftpack import dct, fft2, fftshift
        from scipy import stats

        img = Image.open(image_path).convert("RGB")
        image = np.array(img)

        # Convert to grayscale using cv2 (matches frequency.py)
        if len(image.shape) == 3:
            gray = cv2.cvtColor(image, cv2.COLOR_RGB2GRAY)
        else:
            gray = image

        features = {}

        # --- DCT Features ---
        # Compute 2D DCT (orthonormal normalization)
        dct_map = dct(dct(gray.T, norm='ortho').T, norm='ortho')

        # Histogram of DCT coefficients (excluding DC component)
        dct_coeffs = dct_map.flatten()
        dct_coeffs = dct_coeffs[1:]  # Remove DC component

        # Statistics on DCT coefficients (using absolute values)
        dct_abs = np.abs(dct_coeffs)
        features['dct_mean'] = float(np.mean(dct_abs))
        features['dct_std'] = float(np.std(dct_abs))

        # --- FFT Features ---
        # Compute 2D FFT and shift to center DC component
        f = fft2(gray.astype(np.float64))  # Use float64 for better precision
        fshift = fftshift(f)

        # Use log-magnitude spectrum (20*log10) for consistent normalization
        # This matches the noiseprint extractor and provides better dynamic range
        magnitude_spectrum = 20 * np.log10(np.abs(fshift) + 1e-10)

        # Azimuthal average (Radial Profile)
        # This computes the average magnitude at each radial distance from center
        h, w = magnitude_spectrum.shape
        cy, cx = h // 2, w // 2
        y, x = np.ogrid[-cy:h-cy, -cx:w-cx]
        r = np.sqrt(x**2 + y**2)
        r = r.astype(int)

        # Compute radial profile: average magnitude at each radius
        tbin = np.bincount(r.ravel(), magnitude_spectrum.ravel())
        nr = np.bincount(r.ravel())
        radial_profile = tbin / np.maximum(nr, 1)

        # Remove zero-radius (DC component) for better statistics
        if len(radial_profile) > 1:
            radial_profile_nonzero = radial_profile[1:]
        else:
            radial_profile_nonzero = radial_profile

        # Summary stats of radial profile
        features['fft_radial_mean'] = float(np.mean(radial_profile_nonzero))
        features['fft_radial_std'] = float(np.std(radial_profile_nonzero))

        # Improved radial decay metric: fit linear slope instead of assuming monotonic decay
        # This is more robust to non-monotonic profiles (e.g., peaks at intermediate frequencies)
        n = len(radial_profile_nonzero)
        if n >= 3:
            # Fit linear regression to log(radius) vs magnitude to estimate decay rate
            # This captures the overall trend without assuming monotonicity
            radii = np.arange(1, n + 1, dtype=np.float64)
            # Use log(radius) to better capture power-law decay
            log_radii = np.log(radii + 1e-10)

            # Fit linear model: magnitude = a * log(radius) + b
            # Negative slope indicates decay (typical for natural images)
            # Positive slope indicates high-frequency emphasis (typical for upsampled images)
            try:
                slope, intercept, r_value, p_value, std_err = stats.linregress(
                    log_radii, radial_profile_nonzero
                )
                features['fft_radial_decay'] = float(slope)  # Decay rate (negative = decay)
                features['fft_radial_decay_r2'] = float(r_value**2)  # Goodness of fit
            except:
                # Fallback: simple difference if regression fails
                features['fft_radial_decay'] = float(
                    radial_profile_nonzero[0] - radial_profile_nonzero[-1]
                )
                features['fft_radial_decay_r2'] = 0.0
        else:
            features['fft_radial_decay'] = 0.0
            features['fft_radial_decay_r2'] = 0.0

        # Frequency band energies (low, mid, high)
        if n >= 9:  # Ensure enough samples for band division
            # Divide radial profile into 3 bands: low, mid, high frequency
            edges = np.linspace(0, n, 4, dtype=int)  # 3 bands
            low_band = radial_profile_nonzero[edges[0]:edges[1]]
            mid_band = radial_profile_nonzero[edges[1]:edges[2]]
            high_band = radial_profile_nonzero[edges[2]:edges[3]]

            features['fft_low_energy'] = float(np.mean(low_band))
            features['fft_mid_energy'] = float(np.mean(mid_band))
            features['fft_high_energy'] = float(np.mean(high_band))

            # Peakiness: ratio of max to mean (detects sharp peaks from upsampling)
            # High peakiness indicates periodic patterns (upsampling artifacts)
            profile_mean = np.mean(radial_profile_nonzero)
            profile_max = np.max(radial_profile_nonzero)
            features['fft_peakiness'] = float(profile_max / (profile_mean + 1e-10))
        else:
            # Not enough samples for band analysis
            features['fft_low_energy'] = 0.0
            features['fft_mid_energy'] = 0.0
            features['fft_high_energy'] = 0.0
            features['fft_peakiness'] = 0.0

        result = {
            "tool": "analyze_frequency_domain",
            "status": "completed",
            **features,
        }

        return json.dumps(result)
    except Exception as e:  # pragma: no cover - defensive
        return json.dumps(
            {
                "tool": "analyze_frequency_domain",
                "status": "error",
                "error": str(e),
            }
        )


__all__ = ["analyze_frequency_domain"]