"""Heuristic image feature extraction using OpenCV/numpy/scipy.""" import cv2 import numpy as np from PIL import Image from scipy.stats import entropy as scipy_entropy from typing import Dict def compute_heuristic_features(img: Image.Image) -> Dict[str, float]: """ Compute 9+ heuristic features from a PIL Image using OpenCV. Returns dict with keys: edge_density, contrast, whitespace_ratio, near_white_ratio, color_entropy, saturation_entropy, symmetry_lr, symmetry_tb, sharpness, center_weight """ img_rgb = img.convert("RGB") arr = np.array(img_rgb) gray = cv2.cvtColor(arr, cv2.COLOR_RGB2GRAY) H, W = gray.shape features = {} # 1. Edge density via Canny edges = cv2.Canny(gray, 100, 200) features["edge_density"] = float(edges.sum()) / (255 * H * W + 1e-8) # 2. Contrast (std of luminance / max possible) features["contrast"] = float(gray.std()) / 127.5 # 3. Whitespace ratio (near-white pixels) features["whitespace_ratio"] = float((gray > 240).sum()) / (H * W + 1e-8) features["near_white_ratio"] = float((gray > 200).sum()) / (H * W + 1e-8) # 4. Color entropy (average per-channel) entropies = [] for c in range(3): hist, _ = np.histogram(arr[:, :, c], bins=256, range=(0, 256)) entropies.append(float(scipy_entropy(hist + 1))) features["color_entropy"] = float(np.mean(entropies)) # 5. Saturation entropy (from HSV) hsv = cv2.cvtColor(arr, cv2.COLOR_RGB2HSV) sat_hist, _ = np.histogram(hsv[:, :, 1].ravel(), bins=256, range=(0, 256)) features["saturation_entropy"] = float(scipy_entropy(sat_hist + 1)) # 6. Symmetry (left-right) flipped_lr = np.fliplr(gray).astype(float) gray_f = gray.astype(float) denom_lr = np.sqrt((gray_f ** 2).sum() * (flipped_lr ** 2).sum()) + 1e-8 features["symmetry_lr"] = float((gray_f * flipped_lr).sum() / denom_lr) # 7. Symmetry (top-bottom) flipped_tb = np.flipud(gray).astype(float) denom_tb = np.sqrt((gray_f ** 2).sum() * (flipped_tb ** 2).sum()) + 1e-8 features["symmetry_tb"] = float((gray_f * flipped_tb).sum() / denom_tb) # 8. Sharpness (Laplacian variance, log-normalized) lap_var = cv2.Laplacian(gray, cv2.CV_64F).var() features["sharpness"] = float(np.log1p(lap_var) / 10.0) # 9. Center weight (rule of thirds center vs overall) h3, w3 = max(1, H // 3), max(1, W // 3) if h3 * 2 <= H and w3 * 2 <= W: thirds_zone = gray[h3:2*h3, w3:2*w3] center_mean = float(thirds_zone.mean()) else: center_mean = float(gray.mean()) features["center_weight"] = center_mean / (gray.mean() + 1e-8) return features