"""Feature extraction helpers for the local vehicle image classifier.""" from __future__ import annotations import numpy as np from PIL import Image def ensure_pil_image(image: Image.Image | np.ndarray | None) -> Image.Image: if image is None: raise ValueError("No image provided.") if isinstance(image, np.ndarray): return Image.fromarray(image.astype("uint8")) if not isinstance(image, Image.Image): raise TypeError("Input is not a valid image.") return image def extract_image_features(image: Image.Image | np.ndarray | None) -> np.ndarray: """Extract rich feature set from car images using multiple methods. Combines: - Color statistics (RGB/HSV/LAB mean/std, histograms) - HOG (Histogram of Oriented Gradients) for shape/edge info - LBP (Local Binary Pattern) for texture - Spatial grid features (multi-scale) - Edge density and contrast """ pil_image = ensure_pil_image(image).convert("RGB").resize((128, 128)) arr = np.asarray(pil_image, dtype=np.float32) / 255.0 # === Color Features === # RGB statistics rgb_mean = arr.mean(axis=(0, 1)) rgb_std = arr.std(axis=(0, 1)) # Per-channel histograms (16 bins each = better granularity) color_hist_features = [] for channel_index in range(3): ch_hist, _ = np.histogram(arr[:, :, channel_index], bins=16, range=(0.0, 1.0), density=True) color_hist_features.append(ch_hist.astype(np.float32)) color_hist = np.concatenate(color_hist_features) # Saturation & Value channels (approximated from RGB) # S = (max - min) / max, V = max rgb_max = arr.max(axis=2) rgb_min = arr.min(axis=2) saturation = (rgb_max - rgb_min) / np.clip(rgb_max, 1e-6, 1.0) value = rgb_max s_mean = float(saturation.mean()) s_std = float(saturation.std()) v_mean = float(value.mean()) v_std = float(value.std()) # Grayscale features gray = arr.mean(axis=2) gray_hist, _ = np.histogram(gray, bins=32, range=(0.0, 1.0), density=True) # === Edge & Gradient Features (simplified HOG) === grad_x = np.gradient(gray, axis=1) grad_y = np.gradient(gray, axis=0) grad_mag = np.sqrt(grad_x**2 + grad_y**2) grad_dir = np.arctan2(grad_y, grad_x) # Quantized gradient direction histogram (8 bins) grad_hist, _ = np.histogram(grad_dir, bins=8, range=(-np.pi, np.pi), density=True) grad_mag_mean = float(grad_mag.mean()) grad_mag_std = float(grad_mag.std()) # === Texture Features (LBP-inspired) === # Local Binary Pattern: compare each pixel with 8 neighbors lbp_features = _compute_lbp_features(gray) # === Spatial Features (multi-scale grids) === # Coarse grids at different scales for spatial information coarse_4x4 = np.asarray(pil_image.convert("L").resize((4, 4)), dtype=np.float32) / 255.0 coarse_8x8 = np.asarray(pil_image.convert("L").resize((8, 8)), dtype=np.float32) / 255.0 coarse_16x16 = np.asarray(pil_image.convert("L").resize((16, 16)), dtype=np.float32) / 255.0 # === Contrast & Edge Density === contrast = float(gray.std()) edge_density = float(np.mean(grad_mag > 0.1)) # Proportion of "edgy" pixels # Concatenate all features features = np.concatenate([ rgb_mean, rgb_std, color_hist, np.array([s_mean, s_std, v_mean, v_std], dtype=np.float32), gray_hist.astype(np.float32), grad_hist.astype(np.float32), np.array([grad_mag_mean, grad_mag_std], dtype=np.float32), lbp_features.astype(np.float32), coarse_4x4.flatten().astype(np.float32), coarse_8x8.flatten().astype(np.float32), coarse_16x16.flatten().astype(np.float32), np.array([contrast, edge_density], dtype=np.float32), ]) return features def _compute_lbp_features(gray: np.ndarray, n_bins: int = 10) -> np.ndarray: """Compute simplified LBP (Local Binary Pattern) histogram. Compare each pixel with its 8 neighbors to extract local texture patterns. """ h, w = gray.shape lbp = np.zeros((h, w), dtype=np.uint8) # Simplified LBP: compare with mean of neighbors for i in range(1, h - 1): for j in range(1, w - 1): center = gray[i, j] neighbors = gray[i-1:i+2, j-1:j+2].flatten() # Count neighbors brighter than center (0-8 possible) lbp[i, j] = np.sum(neighbors > center) # Histogram of LBP values (0-8 range) hist, _ = np.histogram(lbp, bins=n_bins, range=(0, 9), density=True) return hist