| """Face crop preprocessing for model input.""" | |
| import cv2 | |
| import numpy as np | |
| from typing import List | |
| def preprocess(img: np.ndarray, model_img_size: int) -> np.ndarray: | |
| """Resize with letterboxing, normalize to [0,1], convert to CHW.""" | |
| new_size = model_img_size | |
| old_size = img.shape[:2] | |
| ratio = float(new_size) / max(old_size) | |
| scaled_shape = tuple([int(x * ratio) for x in old_size]) | |
| interpolation = cv2.INTER_LANCZOS4 if ratio > 1.0 else cv2.INTER_AREA | |
| img = cv2.resize( | |
| img, (scaled_shape[1], scaled_shape[0]), interpolation=interpolation | |
| ) | |
| delta_w = new_size - scaled_shape[1] | |
| delta_h = new_size - scaled_shape[0] | |
| top, bottom = delta_h // 2, delta_h - (delta_h // 2) | |
| left, right = delta_w // 2, delta_w - (delta_w // 2) | |
| img = cv2.copyMakeBorder(img, top, bottom, left, right, cv2.BORDER_REFLECT_101) | |
| img = img.transpose(2, 0, 1).astype(np.float32) / 255.0 | |
| return img | |
| def preprocess_batch(face_crops: List[np.ndarray], model_img_size: int) -> np.ndarray: | |
| """Preprocess multiple face crops into a batched array.""" | |
| if not face_crops: | |
| raise ValueError("face_crops list cannot be empty") | |
| batch = np.zeros( | |
| (len(face_crops), 3, model_img_size, model_img_size), dtype=np.float32 | |
| ) | |
| for i, face_crop in enumerate(face_crops): | |
| batch[i] = preprocess(face_crop, model_img_size) | |
| return batch | |
| def crop(img: np.ndarray, bbox: tuple, bbox_expansion_factor: float) -> np.ndarray: | |
| """Extract square face crop from bbox with expansion. Pad edges with reflection.""" | |
| original_height, original_width = img.shape[:2] | |
| x, y, w, h = bbox | |
| w = w - x | |
| h = h - y | |
| if w <= 0 or h <= 0: | |
| raise ValueError("Invalid bbox dimensions") | |
| max_dim = max(w, h) | |
| center_x = x + w / 2 | |
| center_y = y + h / 2 | |
| x = int(center_x - max_dim * bbox_expansion_factor / 2) | |
| y = int(center_y - max_dim * bbox_expansion_factor / 2) | |
| crop_size = int(max_dim * bbox_expansion_factor) | |
| crop_x1 = max(0, x) | |
| crop_y1 = max(0, y) | |
| crop_x2 = min(original_width, x + crop_size) | |
| crop_y2 = min(original_height, y + crop_size) | |
| top_pad = int(max(0, -y)) | |
| left_pad = int(max(0, -x)) | |
| bottom_pad = int(max(0, (y + crop_size) - original_height)) | |
| right_pad = int(max(0, (x + crop_size) - original_width)) | |
| if crop_x2 > crop_x1 and crop_y2 > crop_y1: | |
| img = img[crop_y1:crop_y2, crop_x1:crop_x2, :] | |
| else: | |
| img = np.zeros((0, 0, 3), dtype=img.dtype) | |
| result = cv2.copyMakeBorder( | |
| img, | |
| top_pad, | |
| bottom_pad, | |
| left_pad, | |
| right_pad, | |
| cv2.BORDER_REFLECT_101, | |
| ) | |
| if result.shape[0] != crop_size or result.shape[1] != crop_size: | |
| raise ValueError( | |
| f"Crop size mismatch: expected {crop_size}x{crop_size}, got {result.shape[0]}x{result.shape[1]}" | |
| ) | |
| return result | |