| import cv2 |
| import numpy as np |
|
|
| def extract_features(img: np.ndarray, log_callback=None): |
| """ |
| Extracts a comprehensive set of handcrafted CV features from an image. |
| Returns a flat feature vector (numpy array) and a dictionary of grouped features. |
| |
| If log_callback is provided, it will be called with status strings for frontend terminal logs. |
| """ |
| |
| def log(msg): |
| if log_callback: |
| log_callback(msg) |
|
|
| features_dict = {} |
|
|
| |
| log("Applying scale normalization...") |
| |
| img = cv2.resize(img, (1024, 1024)) |
| |
| |
| log("Extracting brightness and contrast...") |
| gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) |
| |
| |
| blurred = cv2.GaussianBlur(gray, (3, 3), 0) |
| |
| mean_brightness = np.mean(gray) |
| std_contrast = np.std(gray) |
| |
| features_dict['brightness'] = float(mean_brightness) * 0.1 |
| features_dict['contrast'] = float(std_contrast) * 0.1 |
|
|
| |
| log("Estimating JPEG compression artifacts...") |
| |
| _, encoded = cv2.imencode('.jpg', gray, [int(cv2.IMWRITE_JPEG_QUALITY), 10]) |
| decoded = cv2.imdecode(encoded, cv2.IMREAD_GRAYSCALE) |
| blockiness_diff = np.mean(np.abs(gray.astype(int) - decoded.astype(int))) |
| |
| features_dict['compression_diff'] = float(blockiness_diff) * 0.01 |
|
|
| |
| log("Measuring saturation and color stats...") |
| hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV) |
| mean_saturation = np.mean(hsv[:, :, 1]) |
| |
| features_dict['saturation'] = float(mean_saturation) * 0.1 |
| |
| |
| log("Measuring Laplacian sharpness...") |
| laplacian_var = cv2.Laplacian(blurred, cv2.CV_64F).var() |
| features_dict['laplacian_var'] = float(laplacian_var) * 0.1 |
|
|
| |
| log("Running Canny edge analysis...") |
| edges = cv2.Canny(blurred, 100, 200) |
| edge_density = np.sum(edges > 0) / edges.size |
| features_dict['edge_density'] = float(edge_density) * 0.1 |
|
|
| |
| sobelx = cv2.Sobel(blurred, cv2.CV_64F, 1, 0, ksize=3) |
| sobely = cv2.Sobel(blurred, cv2.CV_64F, 0, 1, ksize=3) |
| sobel_mag = np.sqrt(sobelx**2 + sobely**2) |
| features_dict['sobel_mean'] = float(np.mean(sobel_mag)) * 0.1 |
|
|
| |
| log("Computing FFT frequency energy...") |
| f = np.fft.fft2(gray) |
| fshift = np.fft.fftshift(f) |
| magnitude_spectrum = 20 * np.log(np.abs(fshift) + 1) |
| |
| h, w = gray.shape |
| cy, cx = h // 2, w // 2 |
| r = int(min(h, w) * 0.25) |
| |
| y_grid, x_grid = np.ogrid[:h, :w] |
| mask = (x_grid - cx)**2 + (y_grid - cy)**2 > r**2 |
| |
| high_freq_energy = np.mean(magnitude_spectrum[mask]) |
| low_freq_energy = np.mean(magnitude_spectrum[~mask]) |
| hf_ratio = high_freq_energy / (low_freq_energy + 1e-6) |
| features_dict['fft_hf_ratio'] = float(hf_ratio) |
| |
| |
| |
| flatness = max(0.0, 1.0 - (edge_density / 0.15)) |
| features_dict['moire_score'] = float(hf_ratio * edge_density * flatness * 100) |
|
|
| |
| log("Computing Local Patch FFT...") |
| patch = gray[cy-64:cy+64, cx-64:cx+64] |
| f_patch = np.fft.fftshift(np.fft.fft2(patch)) |
| mag_patch = 20 * np.log(np.abs(f_patch) + 1) |
| |
| mask_patch = np.ones_like(mag_patch, dtype=bool) |
| mask_patch[64-5:64+5, :] = False |
| mask_patch[:, 64-5:64+5] = False |
| features_dict['local_fft_hf'] = float(np.mean(mag_patch[mask_patch])) |
|
|
| |
| |
| log("Checking horizontal/vertical frequency peaks...") |
| h_energy = np.mean(np.abs(fshift[cy, :])) |
| v_energy = np.mean(np.abs(fshift[:, cx])) |
| |
| features_dict['h_freq_peak'] = float(h_energy) * 0.01 |
| features_dict['v_freq_peak'] = float(v_energy) * 0.01 |
| |
| |
| diag1_energy = np.mean(np.diag(np.abs(fshift))) |
| diag2_energy = np.mean(np.diag(np.fliplr(np.abs(fshift)))) |
| features_dict['diag_freq_peak'] = float(max(diag1_energy, diag2_energy)) * 0.01 |
|
|
| |
| |
| log("Checking moiré and banding cues...") |
| |
| small_img = cv2.resize(img, (64, 64)) |
| unique_colors = len(np.unique(small_img.reshape(-1, small_img.shape[2]), axis=0)) |
| banding_score = 1.0 / (unique_colors + 1) |
| features_dict['banding_score'] = float(banding_score) |
|
|
| |
| log("Estimating glare/overexposure patches...") |
| glare_mask = (gray > 240).astype(np.uint8) |
| glare_ratio = np.sum(glare_mask) / gray.size |
| features_dict['glare_ratio'] = float(glare_ratio) |
| |
| |
| num_labels, labels, stats, centroids = cv2.connectedComponentsWithStats(glare_mask, connectivity=8) |
| largest_glare_patch = 0 |
| if num_labels > 1: |
| |
| largest_glare_patch = np.max(stats[1:, cv2.CC_STAT_AREA]) / gray.size |
| |
| |
| if features_dict.get('moire_score', 0) < 1.0: |
| largest_glare_patch *= 0.1 |
| |
| features_dict['glare_patch_size'] = float(largest_glare_patch) |
|
|
| |
| log("Detecting screen-border perspective contours...") |
| contours, _ = cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) |
| rect_score = 0.0 |
| perspective_score = 0.0 |
| if contours: |
| c = max(contours, key=cv2.contourArea) |
| area = cv2.contourArea(c) |
| x, y, w_b, h_b = cv2.boundingRect(c) |
| bounding_area = w_b * h_b |
| if bounding_area > 0: |
| rect_score = area / bounding_area |
| |
| |
| epsilon = 0.02 * cv2.arcLength(c, True) |
| approx = cv2.approxPolyDP(c, epsilon, True) |
| if len(approx) == 4 and cv2.isContourConvex(approx): |
| perspective_score = area / (gray.size + 1e-6) |
| |
| features_dict['rect_contour_score'] = float(rect_score) |
| features_dict['perspective_score'] = float(perspective_score) |
|
|
| |
| log("Detecting visible black bezel...") |
| |
| |
| border_mask = np.ones_like(gray, dtype=np.uint8) |
| margin = 50 |
| border_mask[margin:-margin, margin:-margin] = 0 |
| dark_pixels = (gray < 30).astype(np.uint8) |
| bezel_pixels = np.sum(dark_pixels & border_mask) |
| bezel_score = bezel_pixels / (np.sum(border_mask) + 1e-6) |
| |
| |
| if rect_score < 0.1: |
| bezel_score = 0.0 |
| |
| features_dict['bezel_score'] = float(bezel_score) |
| |
| |
| log("Estimating printout paper texture...") |
| |
| |
| no_edges_mask = (edges == 0).astype(np.float32) |
| paper_noise = laplacian_var * np.mean(no_edges_mask) |
| |
| |
| if perspective_score < 0.05: |
| paper_noise *= 0.1 |
| |
| features_dict['paper_texture'] = float(paper_noise) |
|
|
| |
| log("Estimating compression blockiness...") |
| |
| |
| if h > 8 and w > 8: |
| r1, r2 = gray[7:-1:8, :], gray[8::8, :] |
| min_r = min(r1.shape[0], r2.shape[0]) |
| row_diff = np.mean(np.abs(r1[:min_r].astype(np.float32) - r2[:min_r].astype(np.float32))) |
| |
| c1, c2 = gray[:, 7:-1:8], gray[:, 8::8] |
| min_c = min(c1.shape[1], c2.shape[1]) |
| col_diff = np.mean(np.abs(c1[:, :min_c].astype(np.float32) - c2[:, :min_c].astype(np.float32))) |
| |
| blockiness = (row_diff + col_diff) / 2 |
| else: |
| blockiness = 0 |
| features_dict['blockiness'] = float(blockiness) |
|
|
| |
| feature_keys = sorted(features_dict.keys()) |
| feature_vector = np.array([features_dict[k] for k in feature_keys], dtype=np.float32) |
|
|
| log("Features extracted successfully.") |
| |
| return feature_vector, features_dict |
|
|
| def get_feature_names(): |
| |
| dummy = np.zeros((32, 32, 3), dtype=np.uint8) |
| _, f_dict = extract_features(dummy) |
| return sorted(f_dict.keys()) |
|
|