ImageScreenAI / metrics /texture_analyzer.py
satyakimitra's picture
Initial commit: ImageScreenAI statistical image screening system
e7f1d57
# Dependencies
import numpy as np
from scipy.stats import entropy
from utils.logger import get_logger
from config.schemas import MetricResult
from config.constants import MetricType
from utils.image_processor import ImageProcessor
from config.constants import TEXTURE_ANALYSIS_PARAMS
# Suppress NumPy warning
np.seterr(divide = 'ignore',
invalid = 'ignore',
)
# Setup Logging
logger = get_logger(__name__)
class TextureAnalyzer:
"""
Statistical texture analysis for AI detection
Core principle:
---------------
- Real photos : Natural texture variation (random but structured)
- AI images : Either too smooth or repetitive patterns
Method:
-------
1. Extract local patches
2. Compute texture features (contrast, entropy)
3. Analyze texture consistency and distribution
4. Detect unnaturally smooth regions
"""
def __init__(self):
"""
Initialize TextureAnalyzer Class
"""
self.patch_size = TEXTURE_ANALYSIS_PARAMS.PATCH_SIZE
self.n_patches = TEXTURE_ANALYSIS_PARAMS.N_PATCHES
self.image_processor = ImageProcessor()
self._rng = np.random.default_rng(seed = TEXTURE_ANALYSIS_PARAMS.RANDOM_SEED)
def detect(self, image: np.ndarray) -> MetricResult:
"""
Run texture analysis
Arguments:
----------
image { np.ndarray } : RGB image array (H, W, 3)
Returns:
--------
{ MetricResult } : Structured Texture-domain metric result containing:
- score : Suspicion score [0.0, 1.0]
- confidence : Reliability of texture evidence
- details : Texture forensics and statistics
"""
try:
logger.debug(f"Running texture analysis on image shape {image.shape}")
# Convert to luminance
luminance = self.image_processor.rgb_to_luminance(image = image)
# Extract patches
patches = self._extract_patches(luminance = luminance)
if (len(patches) == 0):
logger.warning("No patches extracted for texture analysis")
return MetricResult(metric_type = MetricType.TEXTURE,
score = TEXTURE_ANALYSIS_PARAMS.NEUTRAL_SCORE,
confidence = 0.0,
details = {"reason": "no_patches_extracted"},
)
# Compute texture features
texture_features, texture_metadata = self._compute_texture_features(patches = patches)
# Analyze for anomalies
texture_score, texture_details = self._analyze_texture_anomalies(features = texture_features,
metadata = texture_metadata,
)
# Calculate Confidence
confidence = float(np.clip((abs(texture_score - TEXTURE_ANALYSIS_PARAMS.NEUTRAL_SCORE) * 2.0), 0.0, 1.0))
logger.debug(f"Texture analysis: Texture Score={texture_score:.3f}, patches={len(patches)}")
return MetricResult(metric_type = MetricType.TEXTURE,
score = float(texture_score),
confidence = confidence,
details = {"patches_total" : int(len(patches)),
**texture_metadata,
**texture_details,
},
)
except Exception as e:
logger.error(f"Texture analysis failed: {e}")
# Return neutral score on error
return MetricResult(metric_type = MetricType.TEXTURE,
score = TEXTURE_ANALYSIS_PARAMS.NEUTRAL_SCORE,
confidence = 0.0,
details = {"error": "texture_analysis_failed"},
)
def _extract_patches(self, luminance: np.ndarray) -> np.ndarray:
"""
Extract random patches from image
"""
h, w = luminance.shape
if ((h < self.patch_size) or (w < self.patch_size)):
logger.warning(f"Image too small for patch size {self.patch_size}")
return np.array([])
patches = list()
for _ in range(self.n_patches):
y = self._rng.integers(0, h - self.patch_size)
x = self._rng.integers(0, w - self.patch_size)
patch = luminance[y:y+self.patch_size, x:x+self.patch_size]
patches.append(patch)
return np.array(patches)
def _compute_texture_features(self, patches: np.ndarray) -> tuple[dict, dict]:
"""
Compute texture features for each patch
Features:
---------
1. Local contrast (standard deviation)
2. Entropy (randomness)
3. Smoothness (inverse of variance)
4. Edge density
Arguments:
----------
patches { np.ndarray } : Array of patches
Returns:
--------
{ tuple } : A tuple containing
- A dictionary of feature arrays
- A dictionary of texture analysis metadata
"""
contrasts = list()
entropies = list()
smoothnesses = list()
edge_densities = list()
uniform_skipped = 0
for patch in patches:
pmin = patch.min()
pmax = patch.max()
if ((pmax - pmin < 1e-6)):
# skip fully uniform patch entirely
uniform_skipped += 1
continue
# Contrast (std deviation)
contrast = np.std(patch)
contrasts.append(contrast)
# Entropy (using histogram)
hist, _ = np.histogram(patch,
bins = TEXTURE_ANALYSIS_PARAMS.HISTOGRAM_BINS,
range = TEXTURE_ANALYSIS_PARAMS.HISTOGRAM_RANGE,
)
hist = hist / (np.sum(hist) + 1e-10)
ent = entropy(hist + 1e-10)
entropies.append(ent)
# Smoothness (inverse of variance, scaled)
variance = np.var(patch)
smoothness = 1.0 / (1.0 + variance)
smoothnesses.append(smoothness)
# Edge density (using Sobel)
gx, gy = self.image_processor.compute_gradients(luminance = patch)
gradient_mag = np.sqrt(gx**2 + gy**2)
edge_density = np.mean(gradient_mag > TEXTURE_ANALYSIS_PARAMS.EDGE_THRESHOLD)
edge_densities.append(edge_density)
# Construct results in proper format
features = {"contrast" : np.array(contrasts),
"entropy" : np.array(entropies),
"smoothness" : np.array(smoothnesses),
"edge_density" : np.array(edge_densities),
}
metadata = {"patches_used" : int(len(contrasts)),
"uniform_patches_skipped" : int(uniform_skipped),
}
return features, metadata
def _analyze_texture_anomalies(self, features: dict, metadata: dict) -> tuple[float, dict]:
"""
Analyze texture features for AI generation artifacts
Checks:
-------
1. Excessive smoothness (too many overly smooth patches)
2. Entropy distribution (too uniform = suspicious)
3. Contrast consistency
Arguments:
----------
features { dict } : Dictionary of texture features
metadata { dict } : Dictionary of texture analysis metadata
Returns:
--------
{ tuple } : A tuple containing:
- Suspicion score [0.0, 1.0]
- Texture statistics
"""
contrast = features['contrast']
entropy_vals = features['entropy']
smoothness = features['smoothness']
edge_density = features['edge_density']
if ((len(contrast) == 0) or (len(entropy_vals) == 0) or (len(smoothness) == 0) or (len(edge_density) == 0)):
logger.debug("All texture features filtered out; returning neutral score")
return (TEXTURE_ANALYSIS_PARAMS.NEUTRAL_SCORE,
{"reason": "all_texture_features_filtered"},
)
# Early exit: all patches nearly uniform
if (np.all(contrast < 1e-6)):
logger.debug("All texture patches near-uniform; returning neutral score")
return (TEXTURE_ANALYSIS_PARAMS.NEUTRAL_SCORE,
{"reason": "all_patches_near_uniform"},
)
# Smoothness Analysis
smooth_ratio = np.mean(smoothness > TEXTURE_ANALYSIS_PARAMS.SMOOTHNESS_THRESHOLD)
smoothness_anomaly = 0.0
if (smooth_ratio > TEXTURE_ANALYSIS_PARAMS.SMOOTH_RATIO_THRESHOLD):
# More than 40% very smooth patches
smoothness_anomaly = min(1.0, (smooth_ratio - TEXTURE_ANALYSIS_PARAMS.SMOOTH_RATIO_THRESHOLD) * TEXTURE_ANALYSIS_PARAMS.SMOOTH_RATIO_SCALE)
# Entropy distribution Analysis
entropy_cv = np.std(entropy_vals) / (np.mean(entropy_vals) + 1e-10)
entropy_anomaly = 0.0
if (entropy_cv < TEXTURE_ANALYSIS_PARAMS.ENTROPY_CV_THRESHOLD):
# Too uniform
entropy_anomaly = (TEXTURE_ANALYSIS_PARAMS.ENTROPY_CV_THRESHOLD - entropy_cv) * TEXTURE_ANALYSIS_PARAMS.ENTROPY_SCALE
# Contrast distribution Analysis
contrast_cv = np.std(contrast) / (np.mean(contrast) + 1e-10)
contrast_anomaly = 0.0
if (contrast_cv < TEXTURE_ANALYSIS_PARAMS.CONTRAST_CV_LOW):
# Too uniform
contrast_anomaly = (TEXTURE_ANALYSIS_PARAMS.CONTRAST_CV_LOW - contrast_cv) * TEXTURE_ANALYSIS_PARAMS.CONTRAST_LOW_SCALE
elif (contrast_cv > TEXTURE_ANALYSIS_PARAMS.CONTRAST_CV_HIGH):
# Too variable (suspicious)
contrast_anomaly = min(1.0, (contrast_cv - TEXTURE_ANALYSIS_PARAMS.CONTRAST_CV_HIGH) * TEXTURE_ANALYSIS_PARAMS.CONTRAST_HIGH_SCALE)
# Edge density consistency Analysis
edge_cv = np.std(edge_density) / (np.mean(edge_density) + 1e-10)
edge_anomaly = 0.0
if (edge_cv < TEXTURE_ANALYSIS_PARAMS.EDGE_CV_THRESHOLD):
edge_anomaly = (TEXTURE_ANALYSIS_PARAMS.EDGE_CV_THRESHOLD - edge_cv) * TEXTURE_ANALYSIS_PARAMS.EDGE_SCALE
# Clipping Sub-anomalies
smoothness_anomaly = np.clip(smoothness_anomaly, 0.0, 1.0)
entropy_anomaly = np.clip(entropy_anomaly, 0.0, 1.0)
contrast_anomaly = np.clip(contrast_anomaly, 0.0, 1.0)
edge_anomaly = np.clip(edge_anomaly, 0.0, 1.0)
# Combine scores
weights = TEXTURE_ANALYSIS_PARAMS.SUBMETRIC_WEIGHTS
texture_score = (weights['smoothness_anomaly'] * smoothness_anomaly + weights['entropy_anomaly'] * entropy_anomaly + weights['contrast_anomaly'] * contrast_anomaly + weights['edge_anomaly'] * edge_anomaly)
final_score = float(np.clip(texture_score, 0.0, 1.0))
detailed_stats = {"smooth_ratio" : float(smooth_ratio),
"entropy_mean" : float(np.mean(entropy_vals)),
"entropy_cv" : float(entropy_cv),
"contrast_mean" : float(np.mean(contrast)),
"contrast_cv" : float(contrast_cv),
"edge_density_mean" : float(np.mean(edge_density)),
"edge_cv" : float(edge_cv),
}
logger.debug(f"Texture scores - smoothness: {smoothness_anomaly:.3f}, entropy: {entropy_anomaly:.3f}, contrast: {contrast_anomaly:.3f}, edge: {edge_anomaly:.3f}")
return final_score, detailed_stats