Kartikeya Mishra
Deploy SalesCode recapture detector to Space
7c34b66
Raw
History Blame Contribute Delete
9.65 kB
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 = {}
# 0. Scale Normalization & Denoising
log("Applying scale normalization...")
# Resize to 1024x1024 to standardize spatial frequencies across camera types
img = cv2.resize(img, (1024, 1024))
# 1. Brightness and Contrast Stats
log("Extracting brightness and contrast...")
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
# Light denoising for compression artifacts analysis
blurred = cv2.GaussianBlur(gray, (3, 3), 0)
mean_brightness = np.mean(gray)
std_contrast = np.std(gray)
# Scale down brightness/contrast to reduce their impact as primary cues
features_dict['brightness'] = float(mean_brightness) * 0.1
features_dict['contrast'] = float(std_contrast) * 0.1
# 1.5 JPEG Compression / Blockiness Estimate
log("Estimating JPEG compression artifacts...")
# We estimate blockiness by comparing the image to a heavily compressed version of itself
_, 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)))
# Compression alone is not a screen cue. Downweight it heavily.
features_dict['compression_diff'] = float(blockiness_diff) * 0.01
# 2. Saturation and Color Stats
log("Measuring saturation and color stats...")
hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
mean_saturation = np.mean(hsv[:, :, 1])
# Downweight saturation to avoid penalizing bright real objects like flowers
features_dict['saturation'] = float(mean_saturation) * 0.1
# 3. Blur / Sharpness (Laplacian variance)
log("Measuring Laplacian sharpness...")
laplacian_var = cv2.Laplacian(blurred, cv2.CV_64F).var()
features_dict['laplacian_var'] = float(laplacian_var) * 0.1
# 4. Edge Density (Canny/Sobel)
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
# Sobel
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
# 5. FFT High-Frequency Energy & Moiré
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)
# Synthetic moire combo. Fix: Moire is periodic pattern on flat surface.
# Scale down if scene has too much random edge texture (like flowers).
flatness = max(0.0, 1.0 - (edge_density / 0.15))
features_dict['moire_score'] = float(hf_ratio * edge_density * flatness * 100)
# 5.5 Local Patch FFT
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)
# Exclude center cross
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]))
# 6. Horizontal / Vertical Frequency Peaks
# Summing energy along axes
log("Checking horizontal/vertical frequency peaks...")
h_energy = np.mean(np.abs(fshift[cy, :]))
v_energy = np.mean(np.abs(fshift[:, cx]))
# Downweight simple generic axes frequency to prevent overfitting
features_dict['h_freq_peak'] = float(h_energy) * 0.01
features_dict['v_freq_peak'] = float(v_energy) * 0.01
# Diagonal energy (moiré directionality)
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
# 7. Banding / Posterization Score
# Screens often have fewer unique colors or visible banding.
log("Checking moiré and banding cues...")
# Calculate unique colors in a resized patch to estimate banding
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)
# 8. Glare / Overexposure Ratio & Largest Patch
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)
# Largest glare patch size
num_labels, labels, stats, centroids = cv2.connectedComponentsWithStats(glare_mask, connectivity=8)
largest_glare_patch = 0
if num_labels > 1:
# Index 0 is background
largest_glare_patch = np.max(stats[1:, cv2.CC_STAT_AREA]) / gray.size
# Fix: Sunlight isn't glare. Only boost if there is a screen rectangle or high moire
if features_dict.get('moire_score', 0) < 1.0:
largest_glare_patch *= 0.1
features_dict['glare_patch_size'] = float(largest_glare_patch)
# 9. Screen-border / Perspective Rectangle Cues
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
# Check for quadrilateral (perspective rectangle)
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) # How much of the image is the screen
features_dict['rect_contour_score'] = float(rect_score)
features_dict['perspective_score'] = float(perspective_score)
# 9.5 Black Bezel Score
log("Detecting visible black bezel...")
# Bezel is usually dark and near the boundary of the detected screen, or at the edges.
# Simple proxy: percentage of very dark pixels near the image borders
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)
# NEW FIX: only trigger if there's an actual bright rectangular screen inside
if rect_score < 0.1:
bezel_score = 0.0 # dark border alone is not a bezel without a screen
features_dict['bezel_score'] = float(bezel_score)
# 9.6 Printout / Paper Texture
log("Estimating printout paper texture...")
# Paper has high-frequency but low-contrast noise.
# Use standard deviation of high-frequency components without strong edges
no_edges_mask = (edges == 0).astype(np.float32)
paper_noise = laplacian_var * np.mean(no_edges_mask)
# Fix: Only trigger if there are some straight lines/perspective to indicate a physical paper
if perspective_score < 0.05:
paper_noise *= 0.1
features_dict['paper_texture'] = float(paper_noise)
# 10. Compression / Blockiness estimate
log("Estimating compression blockiness...")
# Simple heuristic: differences across 8x8 block boundaries
# Using small diff across rows divisible by 8
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)
# Convert to flat array, sorted by key to ensure consistent ordering
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():
# Return consistent names by generating them on a dummy image
dummy = np.zeros((32, 32, 3), dtype=np.uint8)
_, f_dict = extract_features(dummy)
return sorted(f_dict.keys())