| """ |
| Module 1: Forensic Signal Detector |
| Pixel-level analysis for detecting AI manipulation |
| """ |
|
|
| import cv2 |
| import numpy as np |
| from PIL import Image |
| from typing import Dict |
| import tempfile |
| import os |
|
|
|
|
| class ForensicDetector: |
| """Detects low-level technical anomalies in images.""" |
|
|
| def __init__(self): |
| self.ela_quality = 90 |
|
|
| def analyze(self, image_path: str) -> Dict: |
| """Run all forensic analyses on an image.""" |
| img = cv2.imread(image_path) |
| if img is None: |
| raise ValueError(f"Could not load image: {image_path}") |
|
|
| results = { |
| "fft_score": self._fft_analysis(img), |
| "ela_score": self._ela_analysis(image_path), |
| "noise_score": self._noise_analysis(img), |
| "texture_score": self._texture_consistency(img), |
| "compression_score": self._compression_analysis(image_path), |
| "edge_score": self._edge_coherence(img), |
| "sharpness_score": self._sharpness_analysis(img), |
| "rich_poor_texture_score": self._rich_poor_texture_contrast(img), |
| "color_consistency_score": self._color_channel_analysis(img), |
| "lbp_score": self._local_binary_pattern_analysis(img), |
| "glcm_score": self._glcm_texture_analysis(img), |
| } |
|
|
| |
| |
| |
| |
| |
| |
| directions = { |
| "fft_score": -1, |
| "ela_score": -1, |
| "noise_score": 1, |
| "texture_score": 1, |
| "compression_score": 1, |
| "edge_score": 1, |
| "sharpness_score": 1, |
| "rich_poor_texture_score": -1, |
| "color_consistency_score": 1, |
| "lbp_score": -1, |
| "glcm_score": 1, |
| } |
| |
| |
| corrected = {} |
| for k, d in directions.items(): |
| if d == -1: |
| corrected[k] = 1.0 - results[k] |
| else: |
| corrected[k] = results[k] |
| |
| |
| weights = { |
| "fft_score": 0.15, |
| "ela_score": 0.12, |
| "noise_score": 0.18, |
| "texture_score": 0.16, |
| "compression_score": 0.05, |
| "edge_score": 0.01, |
| "sharpness_score": 0.16, |
| "rich_poor_texture_score": 0.03, |
| "color_consistency_score": 0.06, |
| "lbp_score": 0.03, |
| "glcm_score": 0.05, |
| } |
|
|
| results["aggregate_score"] = sum( |
| corrected[k] * weights[k] for k in weights |
| ) |
|
|
| return results |
|
|
| def _fft_analysis(self, img: np.ndarray) -> float: |
| """ |
| FFT analysis to detect GAN/diffusion artifacts. |
| |
| Research-based improvements: |
| 1. Detect periodic artifacts at periods 2, 4, 8, 16 (diffusion fingerprints) |
| 2. DEFEND-style weighted band analysis (mid-high freq more discriminative) |
| 3. Radial symmetry analysis |
| """ |
| gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) |
| h, w = gray.shape |
|
|
| |
| f_transform = np.fft.fft2(gray) |
| f_shift = np.fft.fftshift(f_transform) |
| magnitude = np.abs(f_shift) |
|
|
| center_h, center_w = h // 2, w // 2 |
|
|
| |
| |
| |
| period_score = self._detect_periodic_artifacts(magnitude, h, w) |
|
|
| |
| |
| |
| band_score = self._analyze_frequency_bands(magnitude, h, w) |
|
|
| |
| |
| log_magnitude = np.log(magnitude + 1) |
| mag_norm = (log_magnitude - log_magnitude.min()) / (log_magnitude.max() - log_magnitude.min() + 1e-10) |
|
|
| dc_radius = min(h, w) // 20 |
| angles = np.linspace(0, 2 * np.pi, 36) |
| radii = np.linspace(dc_radius, min(h, w) // 4, 15) |
| radial_profile = [] |
|
|
| for r in radii: |
| ring_values = [] |
| for angle in angles: |
| y_coord = int(center_h + r * np.sin(angle)) |
| x_coord = int(center_w + r * np.cos(angle)) |
| if 0 <= y_coord < h and 0 <= x_coord < w: |
| ring_values.append(mag_norm[y_coord, x_coord]) |
| if ring_values: |
| radial_profile.append(np.std(ring_values)) |
|
|
| if radial_profile: |
| symmetry_score = 1.0 - np.clip(np.mean(radial_profile) * 5, 0, 1) |
| else: |
| symmetry_score = 0.5 |
|
|
| |
| |
| score = 0.40 * period_score + 0.40 * band_score + 0.20 * symmetry_score |
|
|
| return float(np.clip(score, 0, 1)) |
|
|
| def _detect_periodic_artifacts(self, magnitude: np.ndarray, h: int, w: int) -> float: |
| """ |
| Detect periodic artifacts at periods 2, 4, 8, 16. |
| |
| Diffusion models use upsampling that creates repeating patterns. |
| In frequency domain, period P artifact appears at frequency f = N/P |
| where N is the image dimension. |
| """ |
| center_h, center_w = h // 2, w // 2 |
|
|
| |
| periods = [2, 4, 8, 16] |
|
|
| |
| artifact_scores = [] |
|
|
| for period in periods: |
| |
| freq_h = h // period |
| freq_w = w // period |
|
|
| |
| |
| positions = [ |
| (center_h + freq_h, center_w), |
| (center_h - freq_h, center_w), |
| (center_h, center_w + freq_w), |
| (center_h, center_w - freq_w), |
| ] |
|
|
| |
| artifact_energy = [] |
| background_energy = [] |
|
|
| for pos_h, pos_w in positions: |
| if 0 <= pos_h < h and 0 <= pos_w < w: |
| |
| window_size = max(3, min(h, w) // 100) |
| h_start = max(0, pos_h - window_size) |
| h_end = min(h, pos_h + window_size + 1) |
| w_start = max(0, pos_w - window_size) |
| w_end = min(w, pos_w + window_size + 1) |
|
|
| artifact_energy.append(np.mean(magnitude[h_start:h_end, w_start:w_end])) |
|
|
| |
| offset = window_size * 3 |
| bg_h = min(h - 1, max(0, pos_h + offset)) |
| bg_w = min(w - 1, max(0, pos_w + offset)) |
| bg_h_start = max(0, bg_h - window_size) |
| bg_h_end = min(h, bg_h + window_size + 1) |
| bg_w_start = max(0, bg_w - window_size) |
| bg_w_end = min(w, bg_w + window_size + 1) |
|
|
| background_energy.append(np.mean(magnitude[bg_h_start:bg_h_end, bg_w_start:bg_w_end])) |
|
|
| if artifact_energy and background_energy: |
| |
| |
| ratio = np.mean(artifact_energy) / (np.mean(background_energy) + 1e-10) |
| |
| artifact_scores.append(np.clip((ratio - 1.0) / 1.0, 0, 1)) |
|
|
| if artifact_scores: |
| |
| return float(max(artifact_scores)) |
| return 0.0 |
|
|
| def _analyze_frequency_bands(self, magnitude: np.ndarray, h: int, w: int) -> float: |
| """ |
| DEFEND-style frequency band analysis. |
| |
| Research finding: |
| - Low frequencies: similar for real and AI (not discriminative) |
| - Mid frequencies: somewhat discriminative |
| - High frequencies: most discriminative (AI images smoother here) |
| |
| Real images have more high-frequency content (fine details, sensor noise). |
| AI images are smoother in high frequencies. |
| """ |
| center_h, center_w = h // 2, w // 2 |
| max_radius = min(h, w) // 2 |
|
|
| |
| y, x = np.ogrid[:h, :w] |
| distance = np.sqrt((y - center_h) ** 2 + (x - center_w) ** 2) |
|
|
| |
| |
| low_mask = distance < (max_radius * 0.2) |
| mid_mask = (distance >= max_radius * 0.2) & (distance < max_radius * 0.5) |
| high_mask = (distance >= max_radius * 0.5) & (distance < max_radius) |
|
|
| |
| low_energy = np.mean(magnitude[low_mask]) if np.any(low_mask) else 0 |
| mid_energy = np.mean(magnitude[mid_mask]) if np.any(mid_mask) else 0 |
| high_energy = np.mean(magnitude[high_mask]) if np.any(high_mask) else 0 |
|
|
| total_energy = low_energy + mid_energy + high_energy + 1e-10 |
|
|
| |
| |
| |
| high_ratio = high_energy / total_energy |
|
|
| |
| mid_to_low = mid_energy / (low_energy + 1e-10) |
|
|
| |
| |
| |
| |
| |
| if high_ratio < 0.05: |
| smoothness_score = 0.9 |
| elif high_ratio < 0.10: |
| smoothness_score = 0.6 |
| elif high_ratio < 0.15: |
| smoothness_score = 0.4 |
| else: |
| smoothness_score = 0.2 |
|
|
| |
| |
| uniformity_score = 1.0 - np.clip(abs(mid_to_low - 0.5) * 2, 0, 1) |
|
|
| |
| return float(0.8 * smoothness_score + 0.2 * uniformity_score) |
|
|
| def _ela_analysis(self, image_path: str) -> float: |
| """ |
| Error Level Analysis - detects areas with different compression levels. |
| Spliced/inpainted regions often have different error levels. |
| """ |
| |
| original = Image.open(image_path).convert('RGB') |
|
|
| |
| with tempfile.NamedTemporaryFile(suffix='.jpg', delete=True) as tmp: |
| tmp_path = tmp.name |
| original.save(tmp_path, 'JPEG', quality=self.ela_quality) |
| |
| resaved = Image.open(tmp_path) |
| |
| resaved_arr = np.array(resaved, dtype=np.float32) |
|
|
| |
| orig_arr = np.array(original, dtype=np.float32) |
|
|
| ela = np.abs(orig_arr - resaved_arr) |
|
|
| |
| h, w = ela.shape[:2] |
| block_size = 64 |
| region_scores = [] |
|
|
| for i in range(0, h - block_size, block_size): |
| for j in range(0, w - block_size, block_size): |
| region = ela[i:i + block_size, j:j + block_size] |
| region_scores.append(np.mean(region)) |
|
|
| if len(region_scores) < 4: |
| return 0.5 |
|
|
| |
| ela_variance = np.std(region_scores) / (np.mean(region_scores) + 1e-10) |
|
|
| |
| high_ela_ratio = np.mean(ela > 20) |
|
|
| |
| variance_score = np.clip(ela_variance / 0.5, 0, 1) |
| high_ela_score = np.clip(high_ela_ratio * 10, 0, 1) |
|
|
| score = 0.6 * variance_score + 0.4 * high_ela_score |
|
|
| return float(np.clip(score, 0, 1)) |
|
|
| def _noise_analysis(self, img: np.ndarray) -> float: |
| """ |
| Analyze noise patterns - AI images often have unnatural noise. |
| """ |
| gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY).astype(np.float32) |
|
|
| |
| blurred = cv2.GaussianBlur(gray, (5, 5), 0) |
| noise = gray - blurred |
|
|
| |
| noise_std = np.std(noise) |
|
|
| |
| h, w = noise.shape |
| regions = [ |
| noise[:h // 2, :w // 2], |
| noise[:h // 2, w // 2:], |
| noise[h // 2:, :w // 2], |
| noise[h // 2:, w // 2:] |
| ] |
|
|
| region_stds = [np.std(r) for r in regions] |
| std_variance = np.std(region_stds) |
| std_mean = np.mean(region_stds) |
|
|
| |
| |
| cv = std_variance / (std_mean + 1e-10) |
| uniformity_score = 1 - np.clip(cv * 3, 0, 1) |
|
|
| |
| noise_magnitude_score = 0 |
| if noise_std < 2.5: |
| noise_magnitude_score = 0.8 |
| elif noise_std < 5: |
| noise_magnitude_score = 0.4 |
| elif noise_std > 20: |
| noise_magnitude_score = 0.3 |
|
|
| |
| sample = noise[:min(256, h), :min(256, w)] |
| autocorr = np.abs(np.fft.ifft2(np.abs(np.fft.fft2(sample)) ** 2)) |
| autocorr_score = np.clip(autocorr[1, 1] / (autocorr[0, 0] + 1e-10) * 5, 0, 1) |
|
|
| score = 0.4 * uniformity_score + 0.3 * noise_magnitude_score + 0.3 * autocorr_score |
|
|
| return float(np.clip(score, 0, 1)) |
|
|
| def _texture_consistency(self, img: np.ndarray) -> float: |
| """ |
| Check for unnatural smoothness in textures. |
| AI often produces overly smooth surfaces. |
| """ |
| gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) |
|
|
| |
| kernel_size = 15 |
| local_mean = cv2.blur(gray.astype(np.float32), (kernel_size, kernel_size)) |
| local_sqr_mean = cv2.blur((gray.astype(np.float32)) ** 2, (kernel_size, kernel_size)) |
| local_var = local_sqr_mean - local_mean ** 2 |
|
|
| |
| smooth_threshold = 50 |
| smooth_ratio = np.mean(local_var < smooth_threshold) |
|
|
| |
| sobelx = cv2.Sobel(gray, cv2.CV_64F, 1, 0, ksize=3) |
| sobely = cv2.Sobel(gray, cv2.CV_64F, 0, 1, ksize=3) |
| gradient_mag = np.sqrt(sobelx ** 2 + sobely ** 2) |
|
|
| |
| gradient_mean = np.mean(gradient_mag) |
| gradient_score = 1 - np.clip(gradient_mean / 30, 0, 1) |
|
|
| |
| smooth_score = np.clip((smooth_ratio - 0.2) / 0.5, 0, 1) |
|
|
| score = 0.5 * smooth_score + 0.5 * gradient_score |
|
|
| return float(np.clip(score, 0, 1)) |
|
|
| def _rich_poor_texture_contrast(self, img: np.ndarray) -> float: |
| """ |
| Rich/Poor Texture Contrast Analysis (Research-based). |
| |
| Research finding: |
| - Divide image into "rich texture" patches (high detail: objects, edges) |
| and "poor texture" patches (low detail: sky, plain walls) |
| - Measure noise characteristics in each type |
| - Real images: DIFFERENT noise in rich vs poor areas (camera sensor varies) |
| - AI images: SIMILAR noise everywhere (uniform generation process) |
| |
| A high contrast difference = likely real |
| Low contrast difference = likely AI/manipulated |
| """ |
| gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY).astype(np.float32) |
| h, w = gray.shape |
|
|
| |
| patch_size = 32 |
| rich_patches = [] |
| poor_patches = [] |
|
|
| |
| variance_threshold = 500 |
|
|
| for i in range(0, h - patch_size, patch_size): |
| for j in range(0, w - patch_size, patch_size): |
| patch = gray[i:i + patch_size, j:j + patch_size] |
| patch_var = np.var(patch) |
|
|
| if patch_var > variance_threshold: |
| rich_patches.append(patch) |
| elif patch_var < variance_threshold / 3: |
| poor_patches.append(patch) |
|
|
| |
| if len(rich_patches) < 3 or len(poor_patches) < 3: |
| return 0.5 |
|
|
| |
| def extract_noise(patch): |
| """Extract high-frequency noise from a patch.""" |
| blurred = cv2.GaussianBlur(patch, (5, 5), 0) |
| noise = patch - blurred |
| return noise |
|
|
| rich_noises = [extract_noise(p) for p in rich_patches] |
| poor_noises = [extract_noise(p) for p in poor_patches] |
|
|
| |
| |
| |
| |
|
|
| def noise_stats(noise_patches): |
| stds = [np.std(n) for n in noise_patches] |
| |
| autocorrs = [] |
| for n in noise_patches: |
| if n.size > 1: |
| flat = n.flatten() |
| if len(flat) > 1 and np.std(flat[:-1]) > 0 and np.std(flat[1:]) > 0: |
| corr = np.corrcoef(flat[:-1], flat[1:])[0, 1] |
| if not np.isnan(corr): |
| autocorrs.append(corr) |
| return np.mean(stds), np.mean(autocorrs) if autocorrs else 0 |
|
|
| rich_std, rich_autocorr = noise_stats(rich_noises) |
| poor_std, poor_autocorr = noise_stats(poor_noises) |
|
|
| |
| |
| |
|
|
| |
| std_ratio = rich_std / (poor_std + 1e-10) |
|
|
| |
| |
| if std_ratio > 1.5: |
| std_contrast_score = 0.2 |
| elif std_ratio > 1.2: |
| std_contrast_score = 0.35 |
| elif std_ratio > 1.0: |
| std_contrast_score = 0.5 |
| elif std_ratio > 0.8: |
| std_contrast_score = 0.65 |
| else: |
| std_contrast_score = 0.8 |
|
|
| |
| |
| |
| autocorr_diff = abs(rich_autocorr - poor_autocorr) |
|
|
| |
| |
| if autocorr_diff > 0.1: |
| autocorr_score = 0.25 |
| elif autocorr_diff > 0.05: |
| autocorr_score = 0.4 |
| else: |
| autocorr_score = 0.7 |
|
|
| |
| |
| avg_noise = (rich_std + poor_std) / 2 |
| if avg_noise < 2.0: |
| noise_level_score = 0.8 |
| elif avg_noise < 4.0: |
| noise_level_score = 0.5 |
| else: |
| noise_level_score = 0.25 |
|
|
| |
| score = (0.40 * std_contrast_score + |
| 0.30 * autocorr_score + |
| 0.30 * noise_level_score) |
|
|
| return float(np.clip(score, 0, 1)) |
|
|
| def _compression_analysis(self, image_path: str) -> float: |
| """ |
| Detect compression inconsistencies from splicing. |
| """ |
| img = cv2.imread(image_path) |
|
|
| |
| ycrcb = cv2.cvtColor(img, cv2.COLOR_BGR2YCrCb) |
| y_channel = ycrcb[:, :, 0].astype(np.float32) |
|
|
| |
| h, w = y_channel.shape |
| h8, w8 = (h // 8) * 8, (w // 8) * 8 |
| if h8 < 16 or w8 < 16: |
| return 0.5 |
|
|
| y_cropped = y_channel[:h8, :w8] |
|
|
| |
| boundary_diffs = [] |
| inside_diffs = [] |
|
|
| for i in range(0, h8 - 8, 8): |
| for j in range(0, w8 - 8, 8): |
| |
| boundary_diffs.append(abs(float(y_cropped[i + 7, j + 4]) - float(y_cropped[i + 8, j + 4]))) |
| inside_diffs.append(abs(float(y_cropped[i + 3, j + 4]) - float(y_cropped[i + 4, j + 4]))) |
|
|
| if not boundary_diffs or not inside_diffs: |
| return 0.5 |
|
|
| |
| boundary_mean = np.mean(boundary_diffs) |
| inside_mean = np.mean(inside_diffs) |
|
|
| |
| if inside_mean > 0: |
| ratio = boundary_mean / inside_mean |
| |
| inconsistency_score = np.clip(abs(ratio - 1.0) * 2, 0, 1) |
| else: |
| inconsistency_score = 0.5 |
|
|
| |
| diff_variance = np.std(boundary_diffs) / (np.mean(boundary_diffs) + 1e-10) |
| variance_score = np.clip(diff_variance, 0, 1) |
|
|
| score = 0.5 * inconsistency_score + 0.5 * variance_score |
|
|
| return float(np.clip(score, 0, 1)) |
|
|
| def _edge_coherence(self, img: np.ndarray) -> float: |
| """ |
| Check edge coherence - AI images often have inconsistent edges. |
| """ |
| gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) |
|
|
| |
| edges = cv2.Canny(gray, 50, 150) |
|
|
| |
| edge_density = np.mean(edges > 0) |
|
|
| |
| if edge_density < 0.02: |
| density_score = 0.7 |
| elif edge_density > 0.25: |
| density_score = 0.6 |
| else: |
| density_score = 0.3 |
|
|
| |
| lines = cv2.HoughLinesP(edges, 1, np.pi / 180, threshold=50, minLineLength=30, maxLineGap=10) |
|
|
| if lines is not None and len(lines) > 0: |
| |
| line_lengths = [np.sqrt((l[0][2] - l[0][0]) ** 2 + (l[0][3] - l[0][1]) ** 2) for l in lines] |
| avg_length = np.mean(line_lengths) |
|
|
| |
| length_variance = np.std(line_lengths) / (avg_length + 1e-10) |
| continuity_score = 1 - np.clip(length_variance, 0, 1) |
| else: |
| continuity_score = 0.5 |
|
|
| score = 0.5 * density_score + 0.5 * continuity_score |
|
|
| return float(np.clip(score, 0, 1)) |
|
|
| def _sharpness_analysis(self, img: np.ndarray) -> float: |
| """ |
| Detect oversharpening and overblurring artifacts. |
| Uses Laplacian variance and morphological gradient. |
| |
| Based on empirical analysis: |
| - Real photos: lap_var=400-1500, grad_mean=13-25 |
| - Blur/smooth: lap_var=9-14, grad_mean=7-11 |
| - Oversharp: lap_var=2500-12000+, grad_mean=30-75 |
| """ |
| gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) |
|
|
| |
| laplacian = cv2.Laplacian(gray, cv2.CV_64F) |
| lap_var = laplacian.var() |
|
|
| |
| if lap_var > 3500: |
| sharpness_score = 0.95 |
| elif lap_var > 2200: |
| sharpness_score = 0.80 |
| elif lap_var > 1600: |
| sharpness_score = 0.45 |
| elif lap_var < 30: |
| sharpness_score = 0.75 |
| elif lap_var < 100: |
| sharpness_score = 0.55 |
| else: |
| sharpness_score = 0.20 |
|
|
| |
| kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3)) |
| gradient = cv2.morphologyEx(gray, cv2.MORPH_GRADIENT, kernel) |
| grad_mean = np.mean(gradient) |
|
|
| |
| if grad_mean > 35: |
| halo_score = 0.90 |
| elif grad_mean > 27: |
| halo_score = 0.70 |
| elif grad_mean < 12: |
| halo_score = 0.60 |
| else: |
| halo_score = 0.25 |
|
|
| score = 0.55 * sharpness_score + 0.45 * halo_score |
|
|
| return float(np.clip(score, 0, 1)) |
|
|
| def _color_channel_analysis(self, img: np.ndarray) -> float: |
| """ |
| Color Channel Consistency Analysis (Research Method 3). |
| |
| AI-generated images often have: |
| - Unnatural color channel correlations |
| - Inconsistent noise across R, G, B channels |
| - Unusual saturation patterns |
| |
| Real cameras have consistent color processing pipelines. |
| """ |
| |
| b, g, r = cv2.split(img) |
|
|
| |
| |
| |
| def safe_corrcoef(a, b): |
| a_flat = a.flatten().astype(np.float64) |
| b_flat = b.flatten().astype(np.float64) |
| if np.std(a_flat) < 1e-10 or np.std(b_flat) < 1e-10: |
| return 0.5 |
| corr = np.corrcoef(a_flat, b_flat)[0, 1] |
| return corr if not np.isnan(corr) else 0.5 |
|
|
| rg_corr = safe_corrcoef(r, g) |
| rb_corr = safe_corrcoef(r, b) |
| gb_corr = safe_corrcoef(g, b) |
|
|
| avg_corr = (rg_corr + rb_corr + gb_corr) / 3 |
|
|
| |
| |
| if avg_corr < 0.7: |
| corr_score = 0.7 |
| elif avg_corr > 0.98: |
| corr_score = 0.6 |
| else: |
| corr_score = 0.25 |
|
|
| |
| |
| def get_noise_std(channel): |
| blurred = cv2.GaussianBlur(channel, (5, 5), 0) |
| noise = channel.astype(np.float32) - blurred.astype(np.float32) |
| return np.std(noise) |
|
|
| r_noise = get_noise_std(r) |
| g_noise = get_noise_std(g) |
| b_noise = get_noise_std(b) |
|
|
| |
| |
| noise_std = np.std([r_noise, g_noise, b_noise]) |
| noise_mean = np.mean([r_noise, g_noise, b_noise]) |
|
|
| noise_cv = noise_std / (noise_mean + 1e-10) |
|
|
| if noise_cv > 0.3: |
| noise_score = 0.75 |
| elif noise_cv > 0.15: |
| noise_score = 0.5 |
| else: |
| noise_score = 0.25 |
|
|
| |
| |
| hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV) |
| saturation = hsv[:, :, 1] |
|
|
| sat_mean = np.mean(saturation) |
| sat_std = np.std(saturation) |
|
|
| |
| if sat_std < 30: |
| sat_score = 0.65 |
| elif sat_mean > 200: |
| sat_score = 0.6 |
| else: |
| sat_score = 0.3 |
|
|
| |
| score = 0.35 * corr_score + 0.35 * noise_score + 0.30 * sat_score |
|
|
| return float(np.clip(score, 0, 1)) |
|
|
| def _local_binary_pattern_analysis(self, img: np.ndarray) -> float: |
| """ |
| Local Binary Pattern (LBP) Analysis (Research Method 4). |
| |
| LBP captures micro-texture patterns: |
| - For each pixel, compare with 8 neighbors |
| - Create binary code based on comparisons |
| - Histogram of codes reveals texture characteristics |
| |
| AI images have different LBP distributions than real photos. |
| """ |
| gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) |
| h, w = gray.shape |
|
|
| |
| def compute_lbp(img): |
| img_h, img_w = img.shape |
| lbp = np.zeros_like(img, dtype=np.uint8) |
|
|
| for i in range(1, img_h - 1): |
| for j in range(1, img_w - 1): |
| center = img[i, j] |
| code = 0 |
|
|
| |
| code |= (1 << 7) if img[i-1, j-1] >= center else 0 |
| code |= (1 << 6) if img[i-1, j] >= center else 0 |
| code |= (1 << 5) if img[i-1, j+1] >= center else 0 |
| code |= (1 << 4) if img[i, j+1] >= center else 0 |
| code |= (1 << 3) if img[i+1, j+1] >= center else 0 |
| code |= (1 << 2) if img[i+1, j] >= center else 0 |
| code |= (1 << 1) if img[i+1, j-1] >= center else 0 |
| code |= (1 << 0) if img[i, j-1] >= center else 0 |
|
|
| lbp[i, j] = code |
|
|
| return lbp |
|
|
| |
| sample_size = min(200, h - 2, w - 2) |
| if sample_size < 10: |
| return 0.5 |
| start_h = (h - sample_size) // 2 |
| start_w = (w - sample_size) // 2 |
| sample = gray[start_h:start_h+sample_size, start_w:start_w+sample_size] |
|
|
| lbp = compute_lbp(sample) |
|
|
| |
| hist, _ = np.histogram(lbp.flatten(), bins=256, range=(0, 256)) |
| hist = hist.astype(np.float32) / (hist.sum() + 1e-10) |
|
|
| |
|
|
| |
| |
| uniform_patterns = [0, 1, 2, 3, 4, 6, 7, 8, 12, 14, 15, 16, 24, 28, 30, 31, |
| 32, 48, 56, 60, 62, 63, 64, 96, 112, 120, 124, 126, 127, |
| 128, 129, 131, 135, 143, 159, 191, 192, 193, 195, 199, |
| 207, 223, 224, 225, 227, 231, 239, 240, 241, 243, 247, |
| 248, 249, 251, 252, 253, 254, 255] |
|
|
| uniform_ratio = sum(hist[p] for p in uniform_patterns if p < len(hist)) |
|
|
| |
| |
| if uniform_ratio < 0.7: |
| uniform_score = 0.75 |
| elif uniform_ratio > 0.95: |
| uniform_score = 0.6 |
| else: |
| uniform_score = 0.25 |
|
|
| |
| |
| entropy = -np.sum(hist * np.log2(hist + 1e-10)) |
| max_entropy = np.log2(256) |
| norm_entropy = entropy / max_entropy |
|
|
| if norm_entropy < 0.6: |
| entropy_score = 0.7 |
| elif norm_entropy > 0.9: |
| entropy_score = 0.5 |
| else: |
| entropy_score = 0.3 |
|
|
| |
| |
| max_bin = np.max(hist) |
| if max_bin > 0.1: |
| peak_score = 0.65 |
| else: |
| peak_score = 0.3 |
|
|
| score = 0.40 * uniform_score + 0.35 * entropy_score + 0.25 * peak_score |
|
|
| return float(np.clip(score, 0, 1)) |
|
|
| def _glcm_texture_analysis(self, img: np.ndarray) -> float: |
| """ |
| Grey Level Co-occurrence Matrix (GLCM) Analysis (Research Method 5). |
| |
| GLCM captures texture by analyzing how often pairs of pixel values |
| occur at specific spatial relationships. |
| |
| Features: contrast, correlation, energy, homogeneity |
| AI images often have different GLCM statistics than real photos. |
| """ |
| gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) |
| h, w = gray.shape |
|
|
| |
| levels = 32 |
| gray_quantized = (gray // (256 // levels)).astype(np.uint8) |
|
|
| |
| sample_size = min(200, h - 1, w - 1) |
| if sample_size < 10: |
| return 0.5 |
| start_h = (h - sample_size) // 2 |
| start_w = (w - sample_size) // 2 |
| sample = gray_quantized[start_h:start_h+sample_size, start_w:start_w+sample_size] |
|
|
| |
| glcm = np.zeros((levels, levels), dtype=np.float32) |
|
|
| for i in range(sample.shape[0]): |
| for j in range(sample.shape[1] - 1): |
| glcm[sample[i, j], sample[i, j+1]] += 1 |
|
|
| |
| glcm = glcm / (glcm.sum() + 1e-10) |
|
|
| |
|
|
| |
| i_idx, j_idx = np.ogrid[:levels, :levels] |
|
|
| |
| contrast = np.sum(glcm * (i_idx - j_idx) ** 2) |
|
|
| |
| homogeneity = np.sum(glcm / (1 + np.abs(i_idx - j_idx))) |
|
|
| |
| energy = np.sum(glcm ** 2) |
|
|
| |
| mean_i = np.sum(i_idx * glcm) |
| mean_j = np.sum(j_idx * glcm) |
| std_i = np.sqrt(np.sum(glcm * (i_idx - mean_i) ** 2)) |
| std_j = np.sqrt(np.sum(glcm * (j_idx - mean_j) ** 2)) |
|
|
| if std_i > 1e-10 and std_j > 1e-10: |
| correlation = np.sum(glcm * (i_idx - mean_i) * (j_idx - mean_j)) / (std_i * std_j) |
| else: |
| correlation = 0 |
|
|
| |
|
|
| |
| |
| |
| |
|
|
| |
| if contrast < 50: |
| contrast_score = 0.7 |
| elif contrast < 150: |
| contrast_score = 0.5 |
| else: |
| contrast_score = 0.25 |
|
|
| |
| if homogeneity > 0.8: |
| homog_score = 0.7 |
| elif homogeneity > 0.6: |
| homog_score = 0.45 |
| else: |
| homog_score = 0.25 |
|
|
| |
| if energy > 0.1: |
| energy_score = 0.7 |
| elif energy > 0.05: |
| energy_score = 0.45 |
| else: |
| energy_score = 0.25 |
|
|
| |
| score = 0.35 * contrast_score + 0.35 * homog_score + 0.30 * energy_score |
|
|
| return float(np.clip(score, 0, 1)) |
|
|